From ea4ac2cb623b0d1ecdf6e83e590615ffdd543175 Mon Sep 17 00:00:00 2001 From: Michael Sober Date: Wed, 29 Apr 2026 00:15:23 +0200 Subject: [PATCH 1/4] docs: add DataStore to Apollo Client migration guide with backend conflict resolution cleanup (#8578) --- cspell.json | 12 +- src/directory/directory.mjs | 24 + .../add-local-caching/index.mdx | 531 ++++++++++++ .../advanced-patterns/index.mdx | 563 +++++++++++++ .../disable-conflict-resolution/index.mdx | 244 ++++++ .../migrate-from-datastore/index.mdx | 181 +++++ .../migrate-crud-operations/index.mdx | 734 +++++++++++++++++ .../migrate-relationships/index.mdx | 507 ++++++++++++ .../set-up-apollo/index.mdx | 767 ++++++++++++++++++ 9 files changed, 3562 insertions(+), 1 deletion(-) create mode 100644 src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/add-local-caching/index.mdx create mode 100644 src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/advanced-patterns/index.mdx create mode 100644 src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/disable-conflict-resolution/index.mdx create mode 100644 src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/index.mdx create mode 100644 src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/migrate-crud-operations/index.mdx create mode 100644 src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/migrate-relationships/index.mdx create mode 100644 src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/set-up-apollo/index.mdx diff --git a/cspell.json b/cspell.json index 84db10cb3d8..f33889991db 100644 --- a/cspell.json +++ b/cspell.json @@ -1640,7 +1640,17 @@ "uppercased", "autoclosure", "Kiro", - "kiro" + "kiro", + "Persistor", + "persistor", + "Dexie", + "dexie", + "dedup", + "Dedup", + "posttags", + "callouts", + "immer", + "ERESOLVE" ], "flagWords": ["hte", "full-stack", "Full-stack", "Full-Stack", "sudo"], "patterns": [ diff --git a/src/directory/directory.mjs b/src/directory/directory.mjs index c0872866a21..a3a92b68720 100644 --- a/src/directory/directory.mjs +++ b/src/directory/directory.mjs @@ -1892,6 +1892,30 @@ export const directory = { }, { path: 'src/pages/gen1/[platform]/build-a-backend/more-features/datastore/app-uninstall/index.mdx' + }, + { + path: 'src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/index.mdx', + children: [ + { + path: 'src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/set-up-apollo/index.mdx' + }, + { + path: 'src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/migrate-crud-operations/index.mdx' + }, + { + path: 'src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/migrate-relationships/index.mdx' + }, + { + path: 'src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/add-local-caching/index.mdx' + }, + + { + path: 'src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/advanced-patterns/index.mdx' + }, + { + path: 'src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/disable-conflict-resolution/index.mdx' + } + ] } ] }, diff --git a/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/add-local-caching/index.mdx b/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/add-local-caching/index.mdx new file mode 100644 index 00000000000..162ca7a681d --- /dev/null +++ b/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/add-local-caching/index.mdx @@ -0,0 +1,531 @@ +import { getCustomStaticPath } from '@/utils/getCustomStaticPath'; + +export const meta = { + title: 'Optional: Add local caching', + description: 'Enhance your Apollo Client migration with persistent cache, optimistic updates, and intelligent fetch policies.', + platforms: [ + 'angular', + 'javascript', + 'nextjs', + 'react', + 'react-native', + 'vue' + ] +}; + +export const getStaticPaths = async () => { + return getCustomStaticPath(meta.platforms); +}; + +export function getStaticProps(context) { + return { + props: { + platform: context.params.platform, + meta + } + }; +} + +The [Set up Apollo Client](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/set-up-apollo/) page gave you a working Apollo Client with auth, error handling, retry logic, and `new InMemoryCache()`. That cache lives in memory only -- every time the user refreshes the page or reopens the app, every query starts from scratch with a network request and a loading spinner. + +This page adds persistent caching and optimistic updates on top of that foundation. You will configure Apollo's cache to survive page refreshes by persisting it to IndexedDB, gate your app startup on cache restoration, choose the right fetch policy for each query, implement instant UI updates for mutations, and manage cache size with eviction and purge on sign-out. + +## Install persistence libraries + +```bash +npm install apollo3-cache-persist localforage +``` + +- **apollo3-cache-persist** (v0.15.0) -- persists Apollo's `InMemoryCache` to a storage backend +- **localforage** (v1.10.0) -- provides an IndexedDB storage backend with automatic fallback + +## Set up CachePersistor with IndexedDB + +### Configure localforage + +```ts +import localforage from 'localforage'; + +localforage.config({ + driver: localforage.INDEXEDDB, + name: 'myapp-apollo-cache', + storeName: 'apollo_cache', +}); +``` + +### Create the CachePersistor + +```ts +import { CachePersistor, LocalForageWrapper } from 'apollo3-cache-persist'; + +export const persistor = new CachePersistor({ + cache, + storage: new LocalForageWrapper(localforage), + maxSize: 1048576 * 2, // 2MB -- increase if your app caches large datasets + debug: process.env.NODE_ENV === 'development', + trigger: 'write', + key: 'apollo-cache-v1', // Bump when your GraphQL schema changes +}); +``` + + + +**Use `CachePersistor` instead of `persistCache`.** The convenience function `persistCache` does not return the persistor instance, which means you cannot call `purge()` (needed for sign-out), `pause()`/`resume()`, or `getSize()`. For any production app, `CachePersistor` is the right choice. + + + +### Configuration options + +| Option | Default | Purpose | +|--------|---------|---------| +| `cache` | (required) | The `InMemoryCache` instance to persist | +| `storage` | (required) | Storage wrapper -- use `LocalForageWrapper` for IndexedDB | +| `maxSize` | `1048576` (1MB) | Max persisted size in bytes. Set `false` to disable the limit | +| `trigger` | `'write'` | When to persist: `'write'` (on every cache write), `'background'` (on tab visibility change) | +| `debounce` | `1000` | Milliseconds to wait between persist writes | +| `key` | `'apollo-cache-persist'` | Storage key identifier. Version this to invalidate stale caches | +| `debug` | `false` | Log persistence activity to the console | + + + +Here is the complete enhanced `src/apolloClient.ts` that builds on the setup from the previous page. The link chain (retry, error, auth, HTTP) is unchanged -- only the cache configuration, persistor, and default fetch policy are new. + +```ts title="src/apolloClient.ts" +import { + ApolloClient, + InMemoryCache, + createHttpLink, + from, +} from '@apollo/client'; +import { setContext } from '@apollo/client/link/context'; +import { onError } from '@apollo/client/link/error'; +import { RetryLink } from '@apollo/client/link/retry'; +import { CachePersistor, LocalForageWrapper } from 'apollo3-cache-persist'; +import localforage from 'localforage'; +import { fetchAuthSession } from 'aws-amplify/auth'; +import config from '../amplifyconfiguration.json'; + +// --- Configure IndexedDB via localforage --- +localforage.config({ + driver: localforage.INDEXEDDB, + name: 'myapp-apollo-cache', + storeName: 'apollo_cache', +}); + +// --- InMemoryCache --- +const cache = new InMemoryCache({ + typePolicies: { + // See typePolicies section below for full configuration + }, +}); + +// --- Cache Persistor --- +export const persistor = new CachePersistor({ + cache, + storage: new LocalForageWrapper(localforage), + maxSize: 1048576 * 2, + debug: process.env.NODE_ENV === 'development', + trigger: 'write', + key: 'apollo-cache-v1', +}); + +// --- Links (unchanged from Set up Apollo Client page) --- +const httpLink = createHttpLink({ uri: config.aws_appsync_graphqlEndpoint }); + +const authLink = setContext(async (_, { headers }) => { + try { + const session = await fetchAuthSession(); + const token = session.tokens?.idToken?.toString(); + return { headers: { ...headers, authorization: token || '' } }; + } catch (error) { + console.error('Auth session error:', error); + return { headers }; + } +}); + +const errorLink = onError(({ graphQLErrors, networkError }) => { + if (graphQLErrors) { + for (const { message, locations, path } of graphQLErrors) { + console.error(`[GraphQL error]: ${message}, ${locations}, ${path}`); + } + } + if (networkError) { + console.error(`[Network error]: ${networkError}`); + } +}); + +const retryLink = new RetryLink({ + delay: { initial: 300, max: 5000, jitter: true }, + attempts: { max: 3, retryIf: (error) => !!error }, +}); + +// --- Apollo Client --- +export const apolloClient = new ApolloClient({ + link: from([retryLink, errorLink, authLink, httpLink]), + cache, + defaultOptions: { + watchQuery: { fetchPolicy: 'cache-and-network' }, + query: { fetchPolicy: 'cache-and-network' }, + }, +}); +``` + + + +## Cache restoration on app startup + + + +Queries that fire before `persistor.restore()` completes see an empty `InMemoryCache`. Not gating renders on cache restoration is the most common persistence mistake. The symptom is loading spinners on every app launch despite having cached data in IndexedDB. + + + +Call `await persistor.restore()` before rendering any component that uses Apollo queries: + + + +```tsx title="src/App.tsx" +import { useState, useEffect } from 'react'; +import { ApolloProvider } from '@apollo/client'; +import { apolloClient, persistor } from './apolloClient'; + +function App() { + const [cacheReady, setCacheReady] = useState(false); + + useEffect(() => { + persistor.restore().then(() => setCacheReady(true)); + }, []); + + if (!cacheReady) { + return
Loading...
; + } + + return ( + + {/* Your app components */} + + ); +} +``` + +
+ +Once `cacheReady` flips to `true`, every `useQuery` hook inside `ApolloProvider` will find the restored cache data and render immediately -- no network request needed for data that was cached in a previous session. + +## Fetch policy patterns + +Fetch policies control where Apollo reads data from -- cache, network, or both -- on a per-query basis. + +| Policy | Cache Read | Network Fetch | Best For | +|--------|-----------|---------------|----------| +| `cache-first` | Yes (if data exists) | Only on cache miss | Data that rarely changes | +| `cache-and-network` | Yes (immediate) | Always (updates cache after) | **Recommended default.** Shows cached data instantly, then updates from server. | +| `network-only` | No | Always | Force fresh data after a conflict error | +| `cache-only` | Yes | Never | True offline reads | +| `no-cache` | No | Always | One-off sensitive reads | +| `standby` | Yes | Only on manual `refetch()` | Inactive queries | + +### DataStore migration mapping + +| DataStore Pattern | Recommended fetchPolicy | Why | +|-------------------|------------------------|-----| +| `DataStore.query(Model)` (online) | `cache-and-network` | Returns cached data immediately, then updates from server | +| `DataStore.query(Model)` (offline) | `cache-only` | Reads from persistent cache with no network attempt | +| `DataStore.observeQuery()` | `cache-and-network` with `useQuery` | Shows cache first, updates on server response | +| After conflict error | `network-only` | Forces fresh data from server to resolve stale state | + +### Why cache-and-network is the recommended default + +DataStore always showed locally cached data immediately and then synced with the server in the background. `cache-and-network` is the closest Apollo equivalent: + +1. The query reads from cache first (instant render, no loading spinner) +2. Apollo fires a network request in the background +3. When the response arrives, the cache updates and the component re-renders with fresh data + +## Enhanced sign-out with cache purge + + + +**Order matters for sign-out: pause, clearStore, purge, signOut.** If you skip the purge step, the next user who signs in will see the previous user's cached data restored from disk. + + + +```ts title="src/auth.ts" +import { signOut } from 'aws-amplify/auth'; +import { apolloClient, persistor } from './apolloClient'; + +export async function handleSignOut() { + // 1. Pause persistence so clearStore doesn't trigger a write + persistor.pause(); + + // 2. Clear in-memory cache and cancel active queries + await apolloClient.clearStore(); + + // 3. Purge persisted cache from IndexedDB + await persistor.purge(); + + // 4. Sign out from Amplify (clears Cognito tokens) + await signOut(); +} +``` + +**Why this order matters:** + +1. **Pause first** -- `clearStore()` modifies the cache, which would trigger the persistor to write an empty cache to IndexedDB. Pausing prevents that unnecessary write. +2. **Clear in-memory cache** -- removes all cached data from memory and cancels active queries. +3. **Purge IndexedDB** -- deletes the persisted cache from disk so the next user starts fresh. +4. **Sign out last** -- clears Cognito tokens. If you sign out first, `clearStore()` may trigger refetches that fail because the auth token is already invalidated. + +## Optimistic updates + +The [Migrate CRUD operations](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/migrate-crud-operations/) page showed how to create, update, and delete records using Apollo mutations with `refetchQueries`. That approach waits for the server response before the UI updates. Optimistic updates replace `refetchQueries` with instant UI updates that show changes before the server confirms. + +DataStore updated its local store synchronously on `save()`. Apollo's optimistic layer achieves the same instant-UI behavior, but you write it explicitly. + +### How optimistic updates work + +When you provide an `optimisticResponse` to a mutation, Apollo: + +1. Caches the optimistic object in a separate layer (does not overwrite canonical cache data) +2. Active queries re-render immediately with the optimistic data +3. When the server responds, the optimistic layer is discarded and the canonical cache updates +4. On error, the optimistic layer is discarded and the UI reverts automatically -- **zero rollback code needed** + +### Optimistic create + +```ts +const [createPost] = useMutation(CREATE_POST, { + optimisticResponse: ({ input }) => ({ + createPost: { + __typename: 'Post', + id: `temp-${Date.now()}`, + title: input.title, + content: input.content, + status: input.status, + rating: input.rating ?? null, + _version: 1, + _deleted: false, + _lastChangedAt: Date.now(), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }), + update(cache, { data }) { + if (!data?.createPost) return; + cache.updateQuery({ query: LIST_POSTS }, (existing) => { + if (!existing?.listPosts) return existing; + return { + listPosts: { + ...existing.listPosts, + items: [data.createPost, ...existing.listPosts.items], + }, + }; + }); + }, +}); +``` + +The `update` function is needed for creates because Apollo's normalized cache cannot know that a brand-new object should appear in an existing list query. + +### Optimistic update + +```ts +const [updatePost] = useMutation(UPDATE_POST, { + optimisticResponse: { + updatePost: { + __typename: 'Post', + id: post.id, + title: 'Updated Title', + content: post.content, + status: post.status, + rating: 4, + _version: post._version + 1, + _deleted: false, + _lastChangedAt: Date.now(), + createdAt: post.createdAt, + updatedAt: new Date().toISOString(), + }, + }, + // No update function needed -- Apollo auto-merges by __typename + id +}); +``` + +### Optimistic delete + +```ts +const [deletePost] = useMutation(DELETE_POST, { + optimisticResponse: { + deletePost: { + __typename: 'Post', + id: post.id, + _version: post._version + 1, + _deleted: true, + _lastChangedAt: Date.now(), + }, + }, + update(cache, { data }) { + if (!data?.deletePost) return; + cache.evict({ id: cache.identify(data.deletePost) }); + cache.gc(); + }, +}); +``` + +### _version in optimistic responses + +| Operation | Optimistic `_version` | Why | +|-----------|----------------------|-----| +| Create | `1` | New records start at version 1 | +| Update | `post._version + 1` | Predicts the server's version increment | +| Delete | `post._version + 1` | The delete mutation increments the version | + +The optimistic `_version` does not need to be exact. The server response always replaces the optimistic data in the canonical cache. + +## typePolicies for pagination and soft-delete filtering + +### Pagination merge + +Without `typePolicies`, Apollo treats each `(limit, nextToken)` combination as a separate cache entry. A "Load More" button would replace page 1 with page 2 instead of appending. + +```ts +import { InMemoryCache } from '@apollo/client'; + +const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + listPosts: { + keyArgs: ['filter'], + merge(existing, incoming) { + if (!existing) return incoming; + return { + ...incoming, + items: [...(existing.items || []), ...(incoming.items || [])], + }; + }, + read(existing, { readField }) { + if (!existing) return existing; + return { + ...existing, + items: existing.items.filter( + (ref) => !readField('_deleted', ref) + ), + }; + }, + }, + }, + }, + }, +}); +``` + +`keyArgs: ['filter']` tells Apollo that queries with the same filter share a cache entry (pages merge), while different filters are separate entries. + +### Why readField instead of direct property access + +In Apollo's normalized cache, list items are stored as **references** (for example, `{ __ref: "Post:123" }`), not as full objects. You cannot access `ref._deleted` directly. The `readField` helper resolves the reference and reads the field from the normalized cache entry. + +```ts +// WRONG -- ref is a cache reference, not the actual object +items.filter((ref) => !ref._deleted) + +// CORRECT -- readField resolves the reference +items.filter((ref) => !readField('_deleted', ref)) +``` + + + +```ts +const cache = new InMemoryCache({ + typePolicies: { + Post: { keyFields: ['id'] }, + Comment: { keyFields: ['id'] }, + Query: { + fields: { + listPosts: { + keyArgs: ['filter'], + merge(existing, incoming) { + if (!existing) return incoming; + return { + ...incoming, + items: [...(existing.items || []), ...(incoming.items || [])], + }; + }, + read(existing, { readField }) { + if (!existing) return existing; + return { + ...existing, + items: existing.items.filter( + (ref) => !readField('_deleted', ref) + ), + }; + }, + }, + listComments: { + keyArgs: ['filter'], + merge(existing, incoming) { + if (!existing) return incoming; + return { + ...incoming, + items: [...(existing.items || []), ...(incoming.items || [])], + }; + }, + read(existing, { readField }) { + if (!existing) return existing; + return { + ...existing, + items: existing.items.filter( + (ref) => !readField('_deleted', ref) + ), + }; + }, + }, + }, + }, + }, +}); +``` + +The pattern is the same for every list query: `keyArgs` for filter separation, `merge` for pagination, `read` for soft-delete filtering. Add a field policy for each list query in your schema. + + + +## Cache size management + +### Monitor cache size + +```ts +async function logCacheSize() { + const sizeInBytes = await persistor.getSize(); + if (sizeInBytes !== null) { + console.log(`Cache size: ${(sizeInBytes / 1024).toFixed(1)} KB`); + } +} +``` + +### maxSize behavior + +When the serialized cache exceeds `maxSize`, the persistor stops writing to IndexedDB silently. The in-memory cache continues to work normally. Enable `debug: true` during development to see console warnings. + +### Schema version strategy + +When your GraphQL schema changes, bump the `key` option on your `CachePersistor` (for example, from `'apollo-cache-v1'` to `'apollo-cache-v2'`). This starts with an empty cache -- one cold start in exchange for zero cache migration code. + + + +**Cache not restored before queries run:** +Every page load shows loading spinners briefly. Gate your app rendering on `persistor.restore()` completion. + +**Cache exceeds maxSize silently:** +Recent data is not persisted across refreshes. Increase `maxSize` to 2-5MB and enable `debug: true`. + +**Stale cache after schema changes:** +App crashes with TypeErrors reading cached data. Bump the version in the `key` option. + +**Duplicate items after create:** +Apollo calls the `update` function twice for optimistic mutations (once for optimistic, once for server response). Rely on Apollo's optimistic layer lifecycle, or add an existence check in the `update` function. + +**_deleted records still showing:** +Use `readField('_deleted', ref)` in the `read` function, not direct property access. + + diff --git a/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/advanced-patterns/index.mdx b/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/advanced-patterns/index.mdx new file mode 100644 index 00000000000..2117aa74901 --- /dev/null +++ b/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/advanced-patterns/index.mdx @@ -0,0 +1,563 @@ +import { getCustomStaticPath } from '@/utils/getCustomStaticPath'; + +export const meta = { + title: 'Advanced patterns', + description: 'Handle composite keys, set up GraphQL codegen, migrate React components, and understand what DataStore features have no direct equivalent.', + platforms: [ + 'angular', + 'javascript', + 'nextjs', + 'react', + 'react-native', + 'vue' + ] +}; + +export const getStaticPaths = async () => { + return getCustomStaticPath(meta.platforms); +}; + +export function getStaticProps(context) { + return { + props: { + platform: context.params.platform, + meta + } + }; +} + +This page covers four advanced topics: migrating React components from imperative DataStore calls to declarative Apollo hooks, composite and custom primary keys, GraphQL codegen for type-safe operations, and an honest accounting of DataStore features that have no direct Apollo Client equivalent. + + + +## Migrate React components + +This section shows the core paradigm shift: from imperative state management with DataStore to declarative Apollo hooks. + +### Before: DataStore component + +```tsx +import { useState, useEffect } from 'react'; +import { DataStore } from 'aws-amplify/datastore'; +import { Post } from './models'; + +function PostList() { + const [posts, setPosts] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + setLoading(true); + DataStore.query(Post).then(results => { + setPosts(results); + setLoading(false); + }); + }, []); + + const handleDelete = async (post: Post) => { + await DataStore.delete(post); + setPosts(prev => prev.filter(p => p.id !== post.id)); + }; + + if (loading) return

Loading...

; + return ( +
    + {posts.map(post => ( +
  • + {post.title} + +
  • + ))} +
+ ); +} +``` + +### After: Apollo Client component + +```tsx +import { useQuery, useMutation } from '@apollo/client'; +import { LIST_POSTS, DELETE_POST } from './graphql/operations'; + +function PostList() { + const { data, loading, error } = useQuery(LIST_POSTS); + const [deletePost] = useMutation(DELETE_POST, { + refetchQueries: [{ query: LIST_POSTS }], + }); + + const handleDelete = async (post: any) => { + await deletePost({ + variables: { input: { id: post.id, _version: post._version } }, + }); + }; + + if (loading) return

Loading...

; + if (error) return

Error: {error.message}

; + + const posts = data?.listPosts?.items?.filter((p: any) => !p._deleted) || []; + return ( +
    + {posts.map((post: any) => ( +
  • + {post.title} + +
  • + ))} +
+ ); +} +``` + +### Key differences + +| Aspect | DataStore | Apollo Client | +|--------|-----------|---------------| +| Data fetching | `useState` + `useEffect` + `DataStore.query()` | `useQuery()` handles everything | +| Loading state | Manual `useState(true)` / `setLoading(false)` | Built-in `loading` from `useQuery` | +| Error handling | Not exposed | Built-in `error` from `useQuery` | +| Mutation response | Manual state update | `refetchQueries` triggers automatic re-fetch | +| Delete input | Pass the model instance | Must include `id` AND `_version` | +| Soft-deleted records | Filtered automatically | Must filter `_deleted` records manually | + +### Migrate DataStore.observe() + +DataStore's `observe()` returned a single Observable for all change events. The migration replaces this with three separate Amplify subscriptions: + +```tsx +import { useEffect } from 'react'; +import { useQuery } from '@apollo/client'; +import { generateClient } from 'aws-amplify/api'; +import { LIST_POSTS } from './graphql/operations'; + +const amplifyClient = generateClient(); + +function PostList() { + const { data, loading, error, refetch } = useQuery(LIST_POSTS); + + useEffect(() => { + const subscriptions = [ + amplifyClient.graphql({ + query: `subscription OnCreatePost { onCreatePost { id } }`, + }).subscribe({ next: () => refetch() }), + amplifyClient.graphql({ + query: `subscription OnUpdatePost { onUpdatePost { id } }`, + }).subscribe({ next: () => refetch() }), + amplifyClient.graphql({ + query: `subscription OnDeletePost { onDeletePost { id } }`, + }).subscribe({ next: () => refetch() }), + ]; + + return () => subscriptions.forEach(sub => sub.unsubscribe()); + }, [refetch]); + + if (loading) return

Loading...

; + if (error) return

Error: {error.message}

; + + const posts = data?.listPosts?.items?.filter((p: any) => !p._deleted) || []; + return ( +
    + {posts.map((post: any) => ( +
  • {post.title}
  • + ))} +
+ ); +} +``` + +### Migrate DataStore.observeQuery() + +`observeQuery()` combined an initial query with live updates. The Apollo equivalent is `useQuery` with `fetchPolicy: 'cache-and-network'` plus subscription-triggered refetch: + +```tsx +function PublishedPosts() { + const { data, loading, refetch } = useQuery(LIST_POSTS, { + variables: { filter: { status: { eq: 'PUBLISHED' } } }, + fetchPolicy: 'cache-and-network', + }); + + useEffect(() => { + const subscriptions = [ + amplifyClient.graphql({ + query: `subscription OnCreatePost { onCreatePost { id } }`, + }).subscribe({ next: () => refetch() }), + amplifyClient.graphql({ + query: `subscription OnUpdatePost { onUpdatePost { id } }`, + }).subscribe({ next: () => refetch() }), + amplifyClient.graphql({ + query: `subscription OnDeletePost { onDeletePost { id } }`, + }).subscribe({ next: () => refetch() }), + ]; + + return () => subscriptions.forEach(sub => sub.unsubscribe()); + }, [refetch]); + + const posts = data?.listPosts?.items + ?.filter((p: any) => !p._deleted) + ?.sort((a: any, b: any) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ) || []; + + if (loading && !data) return

Loading...

; + + return ( +
+ {loading && Refreshing...} +
    + {posts.map((post: any) => ( +
  • {post.title}
  • + ))} +
+
+ ); +} +``` + +### Owner-based auth subscriptions + + + +When a model has `@auth(rules: [{ allow: owner }])`, you **must** manually pass the `owner` variable to subscriptions. DataStore injected this automatically. Without it, subscriptions connect successfully but never fire events. + + + +```ts +import { fetchAuthSession } from 'aws-amplify/auth'; + +async function getCurrentOwner(): Promise { + const session = await fetchAuthSession(); + // Default Amplify owner field uses the 'sub' claim. + // Check your Gen 1 schema.graphql @auth rules to confirm. + return session.tokens?.idToken?.payload?.sub as string; +} +``` + +Pass the owner to each subscription: + +```ts +amplifyClient.graphql({ + query: `subscription OnCreatePost($owner: String!) { + onCreatePost(owner: $owner) { id } + }`, + variables: { owner }, +}).subscribe({ next: () => refetch() }); +``` + +### React component migration checklist + +**Queries:** +- Replace `useState` + `useEffect` + `DataStore.query()` with `useQuery()` +- Filter `_deleted` records from ALL list query results +- Add `error` state handling +- Use `fetchPolicy: 'cache-and-network'` where you need cached + fresh data + +**Mutations:** +- Replace `DataStore.save(new Model({...}))` with `useMutation(CREATE_MODEL)` +- Replace `DataStore.save(Model.copyOf(...))` with `useMutation(UPDATE_MODEL)` -- include `_version` +- Replace `DataStore.delete(instance)` with `useMutation(DELETE_MODEL)` -- include `_version` +- Add `refetchQueries` to mutations that affect list queries + +**Real-time:** +- Replace `DataStore.observe()` with three Amplify subscriptions +- Replace `DataStore.observeQuery()` with `useQuery` + subscription-triggered `refetch()` +- Add `owner` argument if the model uses owner-based auth +- Clean up ALL subscriptions in the `useEffect` return function + +
+ + + + + +The React-specific hooks (`useQuery`, `useMutation`) shown in other sections of this guide are not available in Angular, vanilla JavaScript, or Vue. Use the imperative Apollo Client APIs (`apolloClient.query()`, `apolloClient.mutate()`) instead. These are the same patterns shown in the "imperative" examples on the [Migrate CRUD operations](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/migrate-crud-operations/) page. + + + + + +## Composite and custom primary keys + +Amplify supports three identifier modes for models. Each mode changes how you query, update, and delete records -- and each requires different Apollo Client configuration. + +### The three identifier modes + +| Identifier Mode | Gen 1 Schema | GraphQL Get Input | Create Input | +|---|---|---|---| +| Default auto-generated ID | No `@primaryKey` directive | `getModel(id: ID!)` | `id` auto-generated by AppSync | +| Custom single-field PK | `@primaryKey(sortKeyFields: [])` on a custom field | `getModel(id: ID!)` | `id` required in create input | +| Composite PK | `@primaryKey(sortKeyFields: ["field2"])` | `getModel(field1: ..., field2: ...)` | All PK fields required | + +### Default (auto ID) + +This is the default mode when you do not use `@primaryKey` on your model. AppSync auto-generates a UUID `id` field. No special migration is needed -- the standard CRUD patterns from the [Migrate CRUD operations](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/migrate-crud-operations/) page apply directly. + +**Gen 1 schema:** + +```graphql +# amplify/backend/api//schema.graphql +type Post @model @auth(rules: [{ allow: owner }]) { + id: ID! + title: String! + content: String + status: String +} +``` + +### Custom single-field PK + +When your model defines a custom primary key field, the `id` is no longer auto-generated. You must provide it explicitly in create mutations. + +**Gen 1 schema:** + +```graphql +# amplify/backend/api//schema.graphql +type Product @model @auth(rules: [{ allow: owner }]) { + id: ID! @primaryKey + sku: String! + name: String! + price: Float +} +``` + +Apollo Client: + +```ts +const { data } = await apolloClient.mutate({ + mutation: CREATE_PRODUCT, + variables: { + input: { + id: 'PROD-001', // REQUIRED -- you must provide this + sku: 'SKU-12345', + name: 'Widget', + price: 29.99, + }, + }, +}); +``` + +### Composite PK + +This mode requires the most migration work. When a model uses `@primaryKey` with `sortKeyFields`, ALL primary key fields become required arguments. + +**Gen 1 schema:** + +```graphql title="amplify/backend/api//schema.graphql" +type StoreBranch @model @auth(rules: [{ allow: owner }]) { + tenantId: ID! @primaryKey(sortKeyFields: ["branchName"]) + branchName: String! + address: String + phone: String +} +``` + +**Apollo Client queries and mutations:** + +```ts +// Query by composite key -- both fields as separate variables +const { data } = await apolloClient.query({ + query: GET_STORE_BRANCH, + variables: { tenantId: 'tenant-123', branchName: 'Downtown' }, +}); + +// Update -- ALL PK fields + _version required in input +await apolloClient.mutate({ + mutation: UPDATE_STORE_BRANCH, + variables: { + input: { + tenantId: 'tenant-123', + branchName: 'Downtown', + address: '456 New St', + _version: data.getStoreBranch._version, + }, + }, +}); +``` + +### Cache configuration for composite keys (typePolicies) + + + +This is the critical configuration step that is easy to miss. Apollo's `InMemoryCache` uses `__typename:id` as the default cache key. Models with composite keys will NOT cache or normalize correctly without explicit `keyFields` configuration. + + + +```ts +import { InMemoryCache } from '@apollo/client'; + +const cache = new InMemoryCache({ + typePolicies: { + // Default models work automatically + Post: { keyFields: ['id'] }, + // Composite key models NEED explicit keyFields + StoreBranch: { keyFields: ['tenantId', 'branchName'] }, + // Custom single-field PK + Product: { keyFields: ['sku'] }, + }, +}); +``` + +**Warning signs that `keyFields` is missing:** queries return stale data after mutations, Apollo DevTools shows duplicate entries, `cache.readQuery` returns `null` for records you know exist. + +## GraphQL codegen for type-safe operations + +The CRUD examples in earlier pages use `(post: any)` casts. This section shows how to eliminate those. + +### Step 1: Generate GraphQL operations + +```bash +amplify codegen +``` + +This generates TypeScript files in `src/graphql/` containing your operations as string constants. + +### Step 2: Wrap with gql() and TypeScript types + +Create a typed operations file that wraps the generated strings: + + + +```ts title="src/graphql/typed-operations.ts" +import { gql, TypedDocumentNode } from '@apollo/client'; +import { getPost as getPostString, listPosts as listPostsString } from './queries'; +import { createPost as createPostString, updatePost as updatePostString, deletePost as deletePostString } from './mutations'; + +export interface Post { + id: string; + title: string; + content: string; + status: string; + rating: number; + createdAt: string; + updatedAt: string; + _version: number; + _deleted: boolean | null; + _lastChangedAt: number; +} + +export interface GetPostData { getPost: Post | null; } +export interface GetPostVars { id: string; } + +export interface ListPostsData { + listPosts: { items: Post[]; nextToken: string | null; }; +} +export interface ListPostsVars { + filter?: Record; + limit?: number; + nextToken?: string; +} + +export interface CreatePostData { createPost: Post; } +export interface CreatePostVars { + input: { title: string; content: string; status?: string; rating?: number; }; +} + +export interface UpdatePostData { updatePost: Post; } +export interface UpdatePostVars { + input: { id: string; _version: number; title?: string; content?: string; }; +} + +export interface DeletePostData { deletePost: Post; } +export interface DeletePostVars { + input: { id: string; _version: number; }; +} + +export const GET_POST: TypedDocumentNode = gql(getPostString); +export const LIST_POSTS: TypedDocumentNode = gql(listPostsString); +export const CREATE_POST: TypedDocumentNode = gql(createPostString); +export const UPDATE_POST: TypedDocumentNode = gql(updatePostString); +export const DELETE_POST: TypedDocumentNode = gql(deletePostString); +``` + + + +### Step 3: Use type-safe hooks + +With `TypedDocumentNode`, Apollo hooks automatically infer data and variable types: + + + +```tsx +import { useQuery, useMutation } from '@apollo/client'; +import { GET_POST, UPDATE_POST } from './graphql/typed-operations'; + +function PostDetail({ postId }: { postId: string }) { + // data is automatically typed as GetPostData + const { data, loading, error } = useQuery(GET_POST, { + variables: { id: postId }, + }); + + const [updatePost] = useMutation(UPDATE_POST); + + async function handleUpdate(title: string) { + const post = data?.getPost; + if (!post) return; + // variables.input is type-checked + await updatePost({ + variables: { input: { id: post.id, title, _version: post._version } }, + }); + } + + if (loading) return

Loading...

; + if (error) return

Error: {error.message}

; + if (!data?.getPost) return

Post not found

; + + const post = data.getPost; // Typed as Post -- no (post: any) cast + return ( +
+

{post.title}

+

{post.content}

+

Rating: {post.rating}

+
+ ); +} +``` + +
+ +## What is lost -- features with no direct equivalent + + + +DataStore provided a managed sync lifecycle with rich event hooks. Apollo Client is a query/cache layer, not a sync engine. This section documents every DataStore feature that has no direct Apollo equivalent, with honest workaround ratings. + + + +### Hub events + +DataStore dispatched 9 distinct events via Hub. Of the 9: + +| Category | Count | Details | +|---|---|---| +| Fully replaced | 0 | None have a direct Apollo equivalent | +| Partially replaced | 2 | `networkStatus` (use browser APIs), `subscriptionsEstablished` (monitor subscription callbacks) | +| No equivalent | 7 | `syncQueriesStarted`, `syncQueriesReady`, `modelSynced`, `outboxMutationEnqueued`, `outboxMutationProcessed`, `outboxStatus`, `storageSubscribed` | + +The 7 with no equivalent describe sync engine behavior, and Apollo Client does not have a sync engine. + +### Selective sync (syncExpressions) + +DataStore's `syncExpressions` let you filter which records synced from server to local store. Apollo Client has no equivalent. + +### Lifecycle methods + +| Method | Apollo Equivalent | Rating | +|---|---|---| +| `DataStore.start()` | None (Apollo queries on demand) | None | +| `DataStore.stop()` | Unsubscribe manually; `apolloClient.stop()` cancels in-flight | None | +| `DataStore.clear()` | `apolloClient.clearStore()` + `persistor.purge()` | Partial | + +### Conflict handler configuration + +This IS covered in the migration guide. Conflicts are handled server-side. **Rating: Full** (different location, same capability). + +### Summary + +| Category | Fully Replaced | Partially Replaced | No Equivalent | +|---|---|---|---| +| Hub lifecycle events (9 total) | 0 | 2 | 7 | +| Selective sync | 0 | 1 | 0 | +| Lifecycle methods (3 total) | 0 | 1 | 2 | +| Conflict handlers | 1 | 0 | 0 | +| **Totals** | **1** | **4** | **9** | + +### Practical guidance + +If your app depends heavily on Hub events for UI state (showing sync progress indicators, outbox status badges), plan additional custom implementation work. For most apps migrating to Apollo Client, these features are not needed because there is no local sync to monitor. The loss is real but the impact is low. diff --git a/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/disable-conflict-resolution/index.mdx b/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/disable-conflict-resolution/index.mdx new file mode 100644 index 00000000000..d2d733e78a2 --- /dev/null +++ b/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/disable-conflict-resolution/index.mdx @@ -0,0 +1,244 @@ +import { getCustomStaticPath } from '@/utils/getCustomStaticPath'; + +export const meta = { + title: 'Disable conflict resolution', + description: 'Disable AppSync conflict resolution and prepare your backend for migration from DataStore to a standard GraphQL client with zero downtime.', + platforms: [ + 'angular', + 'javascript', + 'nextjs', + 'react', + 'react-native', + 'vue' + ] +}; + +export const getStaticPaths = async () => { + return getCustomStaticPath(meta.platforms); +}; + +export function getStaticProps(context) { + return { + props: { + platform: context.params.platform, + meta + } + }; +} + + + +**Complete the frontend migration first.** This page covers the **final backend step** of the migration. Before following these instructions, migrate all frontend clients from DataStore to Apollo Client (or the Amplify API category) using the preceding pages in this guide. Disabling conflict resolution while any client still uses DataStore will break that client immediately. + + + +This page walks through the backend changes required to disable conflict resolution on your AppSync API. Once all frontend clients have been migrated off DataStore, disabling conflict resolution removes the sync infrastructure (version tracking, soft deletes, delta sync) and gives you a simpler, standard GraphQL API. + +## The problem + +When DataStore is enabled, Amplify configures AppSync with conflict resolution. This causes three behaviors that affect migration: + +1. **Soft deletes.** Items deleted through DataStore are not removed from DynamoDB. Instead, DataStore sets `_deleted: true` on the item. The item remains in the table indefinitely (or until a DynamoDB TTL expires it). + +2. **Metadata fields.** Conflict resolution adds `_deleted`, `_version`, and `_lastChangedAt` fields to every model in the generated GraphQL schema. DataStore uses these fields internally for its sync protocol. + +3. **Schema coupling.** If you disable conflict resolution, Amplify removes `_deleted`, `_version`, and `_lastChangedAt` from the GraphQL schema. However, the DynamoDB tables still contain items with these attributes—including soft-deleted items with `_deleted: true`. + +If you swap your frontend from DataStore to a standard GraphQL client (such as Apollo Client) without addressing the `_deleted` field, your app will display soft-deleted items that should be hidden. + +## Overview of the migration steps + +| Step | Action | Purpose | +|------|--------|---------| +| 1 | Disable conflict resolution | Stop AppSync from managing `_version` and soft deletes | +| 2 | Re-add metadata fields to the schema | Keep `_deleted`, `_version`, and `_lastChangedAt` available for clients | +| 3 | Push the changes | Apply the updated configuration to your backend | +| 4 | Clean up (optional) | Purge soft-deleted items from DynamoDB and remove client-side `_deleted` filtering | + +## Step 1: Disable conflict resolution + +Run the Amplify CLI to disable conflict resolution on your GraphQL API: + +```bash +amplify update api +``` + +When prompted: + +1. Select **GraphQL** +2. Walk through the options until you see the conflict resolution setting +3. Select **Disable DataStore for entire API** (or **Disable conflict resolution**, depending on your CLI version) + + + +**Do not run `amplify push` yet.** You need to complete Step 2 first. Pushing at this point would remove the `_deleted`, `_version`, and `_lastChangedAt` fields from the schema while existing data in DynamoDB still contains these attributes. AppSync would return a `ValidationError` for a query or mutation with fields that no longer exist in the schema. + + + +## Step 2: Re-add metadata fields to the GraphQL schema + +After disabling conflict resolution, Amplify removes `_deleted`, `_version`, and `_lastChangedAt` from the schema. However, your DynamoDB tables still contain these attributes on every item. Re-add all three fields to each model in your GraphQL schema file (`amplify/backend/api//schema.graphql`): + +```graphql +type Todo @model @auth(rules: [{ allow: owner }]) { + id: ID! + name: String! + description: String + _deleted: Boolean + _version: Int + _lastChangedAt: AWSTimestamp +} +``` + +```graphql +type Post @model @auth(rules: [{ allow: owner }]) { + id: ID! + title: String! + content: String + status: String + _deleted: Boolean + _version: Int + _lastChangedAt: AWSTimestamp +} +``` + +Add `_deleted: Boolean`, `_version: Int`, and `_lastChangedAt: AWSTimestamp` to **every model** that was previously managed by DataStore. + + + +**Re-add all three fields for consistency.** Even though AppSync no longer manages these fields after disabling conflict resolution, your DynamoDB tables still contain `_deleted`, `_version`, and `_lastChangedAt` on every item. Re-adding them keeps your GraphQL schema aligned with the data in DynamoDB and allows clients to query or filter on any of these attributes during the transition. + + + +### Why this works + +When you add these fields as regular fields in the schema: + +- **AppSync maps each field to the existing DynamoDB attribute.** DynamoDB is schema-less, so `_deleted`, `_version`, and `_lastChangedAt` already exist on every item. AppSync's auto-generated resolvers read and write these attributes directly. +- **The fields appear in generated filter and input types.** Adding them to the model makes them available in `ModelTodoFilterInput`, `ModelPostFilterInput`, and other generated types. Clients can use server-side filtering on `_deleted` and read `_version` or `_lastChangedAt` if needed. +- **It is backwards-compatible.** Existing items retain their values for these attributes. New items created after disabling conflict resolution will have `null` for these fields unless explicitly set. + + + +**These fields are no longer managed by AppSync.** After disabling conflict resolution, `_version` is no longer auto-incremented and `_lastChangedAt` is no longer auto-updated. They become regular fields. Deletes are now hard deletes—`_deleted` is no longer set automatically. + + + +## Step 3: Push the changes + +Apply the updated configuration to your backend: + +```bash +amplify push +``` + +This will: + +- **Disable conflict resolution** in AppSync (removes the sync table configuration and delta sync resolvers) +- **Keep `_deleted`, `_version`, and `_lastChangedAt` as regular fields** in the GraphQL schema +- **Map each field to the existing DynamoDB attribute** on all items (no data migration needed) + + + +After pushing, AppSync no longer manages `_version` or performs soft deletes. New deletes through the GraphQL API will **hard-delete** items from DynamoDB (the default behavior without conflict resolution). + + + +## Step 4 (optional): Clean up soft-deleted items + +Once all clients have been migrated and you have verified that everything works correctly, you can purge the soft-deleted items from DynamoDB and remove the client-side `_deleted` filtering from your code. + +### 4a. Hard-delete soft-deleted items from DynamoDB + +Write a script to scan each DynamoDB table and delete all items where `_deleted` is `true`: + +```ts +import { + DynamoDBClient, + ScanCommand, + DeleteItemCommand, +} from '@aws-sdk/client-dynamodb'; + +const client = new DynamoDBClient({ region: 'us-east-1' }); + +async function purgeSoftDeletedItems(tableName: string) { + let lastEvaluatedKey: Record | undefined; + + do { + const scanResult = await client.send( + new ScanCommand({ + TableName: tableName, + FilterExpression: '#d = :true', + ExpressionAttributeNames: { '#d': '_deleted' }, + ExpressionAttributeValues: { ':true': { BOOL: true } }, + ExclusiveStartKey: lastEvaluatedKey, + }) + ); + + for (const item of scanResult.Items ?? []) { + await client.send( + new DeleteItemCommand({ + TableName: tableName, + Key: { id: item.id }, + }) + ); + console.log(`Deleted item ${item.id.S} from ${tableName}`); + } + + lastEvaluatedKey = scanResult.LastEvaluatedKey; + } while (lastEvaluatedKey); +} + +// Run for each table +await purgeSoftDeletedItems('Todo--'); +await purgeSoftDeletedItems('Post--'); +``` + + + +**Test this script in a development environment first.** Verify that it only deletes items where `_deleted` is `true`. If your tables use composite keys (sort keys), update the `Key` object in the `DeleteItemCommand` accordingly. + + + +### 4b. Update client code + +After purging soft-deleted items, remove the client-side `_deleted` filtering from your code. During the frontend migration, you added `.filter(item => !item._deleted)` to list query results — this is no longer needed once all soft-deleted items have been removed from DynamoDB. + +```ts +// Before cleanup: filter out soft-deleted items +const posts = data.listPosts.items.filter((post) => !post._deleted); + +// After cleanup: no filter needed +const posts = data.listPosts.items; +``` + +## Important considerations + +### Why not purge soft-deleted items first? + +You cannot guarantee that all clients stop using DataStore simultaneously. Users may have cached versions of your app running, or deployments may roll out gradually. Disabling conflict resolution while some clients still expect `_deleted` in the schema causes `ValidationError` exceptions. + +The approach in this guide is backwards-compatible: both DataStore clients (during the transition) and standard GraphQL clients can coexist because `_deleted` remains in the schema as a regular field. + +### DynamoDB TTL and soft-deleted items + +When DataStore is enabled, Amplify may configure a TTL on the `_ttl` attribute in DynamoDB. Soft-deleted items with a TTL will be automatically hard-deleted by DynamoDB after the TTL expires. + +After disabling conflict resolution: +- The TTL configuration on the DynamoDB table **remains active** (it is a table-level setting, not managed by AppSync) +- Existing soft-deleted items with a `_ttl` value will still be automatically removed +- New deletes will be hard deletes (no `_ttl` is set) + +Check your DynamoDB table's TTL settings in the AWS Console under **Tables → your table → Additional settings → Time to Live**. + +### Metadata fields are no longer auto-managed + +After disabling conflict resolution, AppSync no longer auto-increments `_version` or auto-updates `_lastChangedAt` on mutations. The fields retain their last values in DynamoDB and can be read by clients, but they are not updated unless your application code explicitly sets them. During the cleanup step (Step 4), you can purge soft-deleted items and remove the client-side `_deleted` filtering. + +### Monitoring after migration + +After completing the migration, monitor your application for: + +- **`ValidationError` in AppSync CloudWatch logs.** This indicates a client is sending a query or mutation with fields that no longer exist in the schema. +- **Unexpected items in list queries.** If soft-deleted items appear, verify that all list query results are filtered client-side with `.filter(item => !item._deleted)`. +- **`ConditionalCheckFailedException` in DynamoDB.** This should no longer occur after disabling conflict resolution, since `_version` checking is no longer enforced. diff --git a/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/index.mdx b/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/index.mdx new file mode 100644 index 00000000000..4fb9d689e78 --- /dev/null +++ b/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/index.mdx @@ -0,0 +1,181 @@ +import { getCustomStaticPath } from '@/utils/getCustomStaticPath'; +import { getChildPageNodes } from '@/utils/getChildPageNodes'; + +export const meta = { + title: 'Migrate from DataStore', + description: 'Learn how to migrate from Amplify DataStore to Apollo Client for queries, mutations, and caching with Amplify subscriptions for real-time updates.', + route: '/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore', + platforms: [ + 'angular', + 'javascript', + 'nextjs', + 'react', + 'react-native', + 'vue' + ] +}; + +export const getStaticPaths = async () => { + return getCustomStaticPath(meta.platforms); +}; + +export function getStaticProps(context) { + const childPageNodes = getChildPageNodes(meta.route); + return { + props: { + platform: context.params.platform, + meta, + childPageNodes + } + }; +} + +## Understanding DataStore + +AWS Amplify DataStore provided a local-first data layer that automatically synchronized data between your app and the cloud. When you used DataStore, you got several powerful capabilities without writing any synchronization logic yourself: a local database (IndexedDB in the browser) that persisted data across sessions, automatic bidirectional sync with your AppSync backend, built-in conflict resolution using version tracking, full offline support with mutation queuing and replay, and real-time updates through `observe()` and `observeQuery()`. + +DataStore abstracted away the complexity of GraphQL operations, network state management, and data consistency. You worked with simple `save`, `query`, and `delete` methods on local models, and DataStore handled everything else behind the scenes. + +This guide shows you how to use Apollo Client for queries, mutations, and caching, combined with the Amplify library's built-in subscription support for real-time updates. Depending on how much of DataStore's feature set your app actually uses, you may find the migration simpler than expected. + +Once frontend clients have been migrated off DataStore, [disable conflict resolution](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/disable-conflict-resolution/) on the backend to remove the sync infrastructure, simplify mutations (no more `_version` tracking), and switch from soft deletes to hard deletes. + +## What this guide covers + +This guide walks you through the migration path from DataStore to Apollo Client. You will set up Apollo Client with your AppSync endpoint, migrate all CRUD operations and relationships, and optionally add persistent caching and optimistic updates. + +## Quick comparison: before and after + +Here is a quick look at how common DataStore operations translate to Apollo Client: + +| DataStore Operation | Apollo Client Equivalent | +|---------------------|--------------------------| +| `DataStore.save(new Post({...}))` | `apolloClient.mutate({ mutation: CREATE_POST, variables: { input: {...} } })` | +| `DataStore.query(Post)` | `apolloClient.query({ query: LIST_POSTS })` | +| `DataStore.query(Post, id)` | `apolloClient.query({ query: GET_POST, variables: { id } })` | +| `DataStore.delete(post)` | `apolloClient.mutate({ mutation: DELETE_POST, variables: { input: { id, _version } } })` | +| `DataStore.observe(Post)` | `amplifyClient.graphql({ query: onCreatePost }).subscribe(...)` | +| `DataStore.observeQuery(Post)` | `useQuery(LIST_POSTS)` with subscription-triggered `refetch()` | + + + +Subscriptions use the Amplify library's `client.graphql()` rather than Apollo, because AppSync uses a custom WebSocket protocol that Amplify handles natively. Apollo Client handles all queries, mutations, and caching. + + + +## Who should use this guide + +This guide is for developers who have an existing Amplify Gen 1 application that uses DataStore and want to replace DataStore with Apollo Client. It covers the frontend migration (replacing DataStore with Apollo Client) and the backend step of disabling conflict resolution once all clients have been migrated. After completing these steps, you can proceed to migrate your backend to Amplify Gen 2. + + + +**Gen 1 field name casing:** Gen 1 backends use uppercase ID suffixes in foreign keys (e.g., `postID`, `tagID`), while Gen 2 uses lowercase (`postId`, `tagId`). Code examples in this guide use the Gen 2 convention. If your backend still uses Gen 1, adjust all field names in your GraphQL operations, sync queries, and filters to match your schema. Mismatched casing returns `null` silently. Verify field names in your `schema.graphql` or the AppSync console. + + + +It assumes you are familiar with: + +- React and React hooks +- Basic GraphQL concepts (queries, mutations, subscriptions) +- Amplify configuration and the `amplifyconfiguration.json` (or `aws-exports.js`) file +- Your app's data model and how it uses DataStore today + +You do not need prior experience with Apollo Client. The guide covers Apollo Client setup from scratch. + +## How to use this guide + +Follow the pages in order. Each step builds on the previous one. + +1. **[Set up Apollo Client](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/set-up-apollo/).** Install dependencies, define your GraphQL operations, configure Apollo Client with your AppSync endpoint and Cognito authentication, and set up real-time subscriptions with Amplify. + +2. **[Migrate CRUD operations](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/migrate-crud-operations/).** Replace `DataStore.save()`, `DataStore.query()`, and `DataStore.delete()` with Apollo Client queries and mutations. Migrate predicates, pagination, and sorting. + +3. **[Migrate relationships](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/migrate-relationships/).** Migrate `hasMany`, `belongsTo`, `hasOne`, and `manyToMany` relationships from DataStore's lazy-loading model to Apollo Client's GraphQL selection sets. + +4. **[Optional: Add local caching](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/add-local-caching/).** Enhance your setup with persistent caching (data survives page refreshes), optimistic updates (instant UI feedback), and intelligent fetch policies. This step is optional -- skip it if your app does not need data persistence across page refreshes or instant optimistic UI. + +5. **[Advanced patterns](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/advanced-patterns/).** Handle composite keys, set up GraphQL codegen for type safety, migrate React components, and review DataStore features with no direct Apollo equivalent. + +6. **[Disable conflict resolution](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/disable-conflict-resolution/).** Once ALL frontend clients are migrated off DataStore, remove the sync infrastructure, simplify mutations, and switch from soft deletes to hard deletes. + +7. **Migrate your backend to Gen 2 (optional).** After completing the frontend migration and disabling conflict resolution, you can migrate your backend from Gen 1 to Amplify Gen 2. + + + +**Model coverage:** Code examples use Post and Comment models. For additional models (including join tables like PostTag), extend the same patterns. + + + +## Migration checklists + +Use these checklists to plan and track your migration from DataStore to Apollo Client. Check items off as you complete them. + +### Pre-migration checklist + +Complete these steps before writing any migration code: + +- Verify your backend is deployed and `amplifyconfiguration.json` (or `aws-exports.js`) is generated (`amplify push`) +- Confirm Amplify is configured with `Amplify.configure(config)` running at app startup before any API calls +- Inventory all DataStore usage in your codebase: `DataStore.save()`, `DataStore.query()`, `DataStore.delete()`, `DataStore.observe()`, `DataStore.observeQuery()` +- Identify all models and relationships (`hasMany`, `belongsTo`, `hasOne`, `manyToMany`), noting which models have custom or composite primary keys +- Write GraphQL operations for each model, including `_version`, `_deleted`, and `_lastChangedAt` in all fragments +- Install Apollo Client: `npm install @apollo/client@^3.14.0 graphql` +- Set up Apollo Client and verify the connection works by running a simple list query against your AppSync endpoint +- Set up the Amplify subscription client using `generateClient()` + +### During migration checklist + +Follow these steps while migrating each feature. Work through one model at a time to keep changes manageable and testable. + +**For each DataStore model:** + +- Define a GraphQL fragment including all business fields plus `_version`, `_deleted`, and `_lastChangedAt` +- Define all GraphQL operations (list, get, create, update, delete) using the fragment +- Migrate list queries, filtering out soft-deleted records (`_deleted: true`) in the results +- Migrate single-item queries +- Migrate creates (no `_version` needed for creates) +- Migrate updates (include `_version` from the latest query result in the mutation input) +- Migrate deletes (include both `id` and `_version` in the mutation input) +- Migrate `observe` using Amplify subscription plus the refetch pattern +- Migrate `observeQuery` using `useQuery` combined with subscription-triggered `refetch()` +- Update error handling for Apollo's error link and component-level error states +- Test each migrated operation before moving to the next model + +**For predicates and filters:** + +- Convert DataStore predicates to GraphQL filter objects +- Migrate sorting to client-side `.sort()` or server-side `@index` queries +- Migrate pagination from page-based to cursor-based (`nextToken` and `limit`) + +### Post-migration checklist + +**Verification:** + +- Verify all CRUD operations work for every migrated model +- Verify real-time updates fire for all three event types (create, update, delete) on every model +- Verify authentication flow including sign-in, authenticated operations, and sign-out +- Verify sign-out cleanup clears the Apollo cache +- Verify `_version` handling succeeds without `ConditionalCheckFailedException` errors +- Verify soft-delete filtering so deleted records no longer appear in list views + +**Cleanup:** + +- Remove all DataStore imports from your codebase +- Remove generated DataStore model files +- Remove DataStore configuration calls (`DataStore.configure()`, `DataStore.start()`, `DataStore.stop()`) +- Remove `@aws-amplify/datastore` from your dependencies +- Run the app end-to-end with a full user workflow +- Monitor for errors post-deployment + + + +If you followed the optional [Add local caching](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/add-local-caching/) step, add these items to your migration plan: + +- Set up `apollo3-cache-persist` for persistent cache storage +- Configure `fetchPolicy` for each query (for example, `cache-and-network` for lists, `cache-first` for detail views) +- Implement optimistic updates for mutations using Apollo's `optimisticResponse` option +- Update the sign-out flow to purge the persistent cache in addition to clearing the in-memory cache + + + + diff --git a/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/migrate-crud-operations/index.mdx b/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/migrate-crud-operations/index.mdx new file mode 100644 index 00000000000..503f2c40114 --- /dev/null +++ b/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/migrate-crud-operations/index.mdx @@ -0,0 +1,734 @@ +import { getCustomStaticPath } from '@/utils/getCustomStaticPath'; + +export const meta = { + title: 'Migrate CRUD operations', + description: 'Migrate DataStore save, query, update, delete, predicates, pagination, and sorting to Apollo Client GraphQL operations.', + platforms: [ + 'angular', + 'javascript', + 'nextjs', + 'react', + 'react-native', + 'vue' + ] +}; + +export const getStaticPaths = async () => { + return getCustomStaticPath(meta.platforms); +}; + +export function getStaticProps(context) { + return { + props: { + platform: context.params.platform, + meta + } + }; +} + +This page covers how to migrate every DataStore CRUD operation and predicate/filter pattern to Apollo Client. DataStore conflates create and update into a single `save()` method and handles `_version` internally. With Apollo Client, you use distinct mutations for each operation and manage `_version` explicitly. + +**GraphQL operations used on this page** (`CREATE_POST`, `UPDATE_POST`, `DELETE_POST`, `GET_POST`, `LIST_POSTS`, and the `POST_DETAILS_FRAGMENT` fragment) are defined on the [Set up Apollo Client](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/set-up-apollo/) page. Import them as needed: + +```ts +import { apolloClient } from './apolloClient'; +import { + CREATE_POST, UPDATE_POST, DELETE_POST, + GET_POST, LIST_POSTS, +} from './graphql/operations'; +``` + +## Create (save new record) + +DataStore uses `new Model()` plus `DataStore.save()` to create a record. Apollo Client uses the `CREATE_POST` mutation. + +**DataStore (before):** + +```ts +const newPost = await DataStore.save( + new Post({ + title: 'My First Post', + content: 'Hello world', + status: 'PUBLISHED', + rating: 5, + }) +); +``` + +**Apollo Client (after) -- imperative:** + +```ts +const { data } = await apolloClient.mutate({ + mutation: CREATE_POST, + variables: { + input: { + title: 'My First Post', + content: 'Hello world', + status: 'PUBLISHED', + rating: 5, + }, + }, +}); +const newPost = data.createPost; +// newPost._version is 1 (set by AppSync automatically) +``` + + + +**Apollo Client (after) -- React hook:** + +```tsx +import { useMutation } from '@apollo/client'; + +function CreatePostForm() { + const [createPost, { loading, error }] = useMutation(CREATE_POST, { + refetchQueries: [{ query: LIST_POSTS }], + }); + + async function handleSubmit(title: string, content: string) { + const { data } = await createPost({ + variables: { + input: { title, content, status: 'PUBLISHED', rating: 5 }, + }, + }); + console.log('Created:', data.createPost.id); + } + + return ( +
{ e.preventDefault(); handleSubmit('Title', 'Content'); }}> + {error &&

Error: {error.message}

} + +
+ ); +} +``` + +
+ +**Key differences:** + +- **No `_version` needed for creates.** AppSync sets `_version` to 1 automatically on new records. +- **`refetchQueries`** ensures the list view updates after a create. DataStore handled this automatically through its local store; Apollo requires explicit cache management. + +## Update (modify existing record) + +DataStore uses `Model.copyOf()` with an immer-based draft for immutable updates. Apollo Client uses the `UPDATE_POST` mutation with a plain object. Only changed fields need to be in the input. + + + +**`_version` is REQUIRED for updates.** You must query the record first to get the current `_version`. If you see `ConditionalCheckFailedException`, you are missing or passing a stale `_version`. + + + +**DataStore (before):** + +```ts +const original = await DataStore.query(Post, '123'); +const updated = await DataStore.save( + Post.copyOf(original, (draft) => { + draft.title = 'Updated Title'; + draft.rating = 4; + }) +); +``` + +**Apollo Client (after) -- imperative:** + +```ts +// Step 1: Query the current record to get _version +const { data: queryData } = await apolloClient.query({ + query: GET_POST, + variables: { id: '123' }, +}); +const post = queryData.getPost; + +// Step 2: Mutate with _version from query result +const { data } = await apolloClient.mutate({ + mutation: UPDATE_POST, + variables: { + input: { + id: '123', + title: 'Updated Title', + rating: 4, + _version: post._version, // REQUIRED + }, + }, +}); +``` + + + +**Apollo Client (after) -- React hook:** + +```tsx +import { useQuery, useMutation } from '@apollo/client'; + +function EditPostForm({ postId }: { postId: string }) { + const { data, loading: queryLoading } = useQuery(GET_POST, { + variables: { id: postId }, + }); + const [updatePost, { loading: updating, error }] = useMutation(UPDATE_POST); + + async function handleSave(title: string) { + const post = data.getPost; + await updatePost({ + variables: { + input: { + id: post.id, + title, + _version: post._version, + }, + }, + }); + } + + if (queryLoading) return

Loading...

; + + return ( +
+ {error &&

Error: {error.message}

} + +
+ ); +} +``` + +
+ +**Key differences:** + +- **No `copyOf()` or immer pattern.** Apollo uses plain objects -- pass only the fields you want to change. +- **Only changed fields + `id` + `_version` are needed.** You do not need to send the entire record. +- **Two-step process:** Query first (to get `_version`), then mutate. DataStore handled this internally. + +## Delete (single record) + +**`_version` is REQUIRED for deletes.** You must query the record first to get the current `_version`, even if you already have the ID. + +**DataStore (before):** + +```ts +const post = await DataStore.query(Post, '123'); +await DataStore.delete(post); +``` + +**Apollo Client (after) -- imperative:** + +```ts +// Step 1: Query to get current _version +const { data: queryData } = await apolloClient.query({ + query: GET_POST, + variables: { id: '123' }, +}); + +// Step 2: Delete with _version +await apolloClient.mutate({ + mutation: DELETE_POST, + variables: { + input: { + id: '123', + _version: queryData.getPost._version, + }, + }, + refetchQueries: [{ query: LIST_POSTS }], +}); +``` + + + +**Apollo Client (after) -- React hook:** + +```tsx +import { useMutation } from '@apollo/client'; + +function DeletePostButton({ post }: { post: { id: string; _version: number } }) { + const [deletePost, { loading }] = useMutation(DELETE_POST, { + refetchQueries: [{ query: LIST_POSTS }], + }); + + async function handleDelete() { + await deletePost({ + variables: { + input: { id: post.id, _version: post._version }, + }, + }); + } + + return ( + + ); +} +``` + + + +**Key differences:** + +- **No delete-by-ID shorthand.** Apollo always needs the mutation input object with both `id` and `_version`. +- **Delete is a soft delete** when conflict resolution is enabled. The record's `_deleted` field is set to `true` in DynamoDB, but the record is not physically removed. + +## Query by ID + +**DataStore (before):** + +```ts +const post = await DataStore.query(Post, '123'); +if (post) { + console.log(post.title); +} +``` + +**Apollo Client (after):** + +```ts +const { data } = await apolloClient.query({ + query: GET_POST, + variables: { id: '123' }, +}); +const post = data.getPost; +// Returns null instead of undefined when not found +if (post) { + console.log(post.title); +} +``` + +## List all records + + + +**You must filter out soft-deleted records.** DataStore did this automatically. Apollo Client returns all records including those with `_deleted: true`. Forgetting this is the most common migration bug. + + + +**DataStore (before):** + +```ts +const posts = await DataStore.query(Post); +``` + +**Apollo Client (after):** + +```ts +const { data } = await apolloClient.query({ query: LIST_POSTS }); +const posts = data.listPosts.items.filter((post) => !post._deleted); +``` + +## Batch delete (predicate-based) + +DataStore supported deleting multiple records with a predicate. Apollo Client has no equivalent -- you must query the matching records first, then delete each one individually. + +**DataStore (before):** + +```ts +await DataStore.delete(Post, (p) => p.status.eq('DRAFT')); +``` + +**Apollo Client (after):** + +```ts +// Step 1: Query posts matching the filter +const { data } = await apolloClient.query({ + query: LIST_POSTS, + variables: { filter: { status: { eq: 'DRAFT' } } }, +}); +const drafts = data.listPosts.items.filter((post) => !post._deleted); + +// Step 2: Delete each record individually +const results = await Promise.allSettled( + drafts.map((post) => + apolloClient.mutate({ + mutation: DELETE_POST, + variables: { + input: { id: post.id, _version: post._version }, + }, + }) + ) +); + +// Step 3: Check for partial failures +const failures = results.filter((r) => r.status === 'rejected'); +if (failures.length > 0) { + console.error(`${failures.length} of ${drafts.length} deletes failed`); +} + +// Refresh the list +await apolloClient.refetchQueries({ include: [LIST_POSTS] }); +``` + + + +Use `Promise.allSettled` (not `Promise.all`) so that one failure does not abort the remaining deletes. For large datasets (100+ records), process in batches of 10-25 with a brief delay between batches to avoid AppSync throttling. + + + +## CRUD quick reference + +| DataStore Method | Apollo Client Equivalent | Key Difference | +|---|---|---| +| `DataStore.save(new Model({...}))` | `apolloClient.mutate({ mutation: CREATE, variables: { input: {...} } })` | No `_version` needed for creates | +| `Model.copyOf(original, draft => {...})` + `DataStore.save()` | `apolloClient.mutate({ mutation: UPDATE, variables: { input: { id, _version, ...changes } } })` | Must pass `_version`; plain object instead of immer draft | +| `DataStore.delete(instance)` | `apolloClient.mutate({ mutation: DELETE, variables: { input: { id, _version } } })` | Must query first to get `_version` | +| `DataStore.query(Model, id)` | `apolloClient.query({ query: GET, variables: { id } })` | Returns `null` instead of `undefined` when not found | +| `DataStore.query(Model)` | `apolloClient.query({ query: LIST })` | Must filter `_deleted` records from results | +| `DataStore.delete(Model, predicate)` | Query with filter + delete each individually | No atomicity; use `Promise.allSettled` | + +## Filter operator mapping + +DataStore uses callback-based predicates. Apollo Client and AppSync use JSON filter objects passed as query variables. + +| Operator | DataStore Syntax | GraphQL Syntax | Notes | +|---|---|---|---| +| `eq` | `p.field.eq(value)` | `{ field: { eq: value } }` | Exact match | +| `ne` | `p.field.ne(value)` | `{ field: { ne: value } }` | Not equal | +| `gt` | `p.field.gt(value)` | `{ field: { gt: value } }` | Greater than | +| `ge` | `p.field.ge(value)` | `{ field: { ge: value } }` | Greater than or equal | +| `lt` | `p.field.lt(value)` | `{ field: { lt: value } }` | Less than | +| `le` | `p.field.le(value)` | `{ field: { le: value } }` | Less than or equal | +| `contains` | `p.field.contains(value)` | `{ field: { contains: value } }` | Substring match | +| `notContains` | `p.field.notContains(value)` | `{ field: { notContains: value } }` | Substring not present | +| `beginsWith` | `p.field.beginsWith(value)` | `{ field: { beginsWith: value } }` | String prefix match | +| `between` | `p.field.between(lo, hi)` | `{ field: { between: [lo, hi] } }` | Inclusive range | +| `in` | `p.field.in([v1, v2])` | **NOT AVAILABLE** | Use `or` + `eq` workaround | +| `notIn` | `p.field.notIn([v1, v2])` | **NOT AVAILABLE** | Use `and` + `ne` workaround | + +### Filter examples + +**eq -- Exact match:** + +```ts +// DataStore +const published = await DataStore.query(Post, (p) => p.status.eq('PUBLISHED')); + +// Apollo Client +const { data } = await apolloClient.query({ + query: LIST_POSTS, + variables: { filter: { status: { eq: 'PUBLISHED' } } }, +}); +const published = data.listPosts.items.filter((p) => !p._deleted); +``` + +**contains -- Substring match:** + +```ts +// DataStore +const reactPosts = await DataStore.query(Post, (p) => p.title.contains('React')); + +// Apollo Client +const { data } = await apolloClient.query({ + query: LIST_POSTS, + variables: { filter: { title: { contains: 'React' } } }, +}); +const reactPosts = data.listPosts.items.filter((p) => !p._deleted); +``` + +**between -- Inclusive range:** + +```ts +// DataStore +const midRated = await DataStore.query(Post, (p) => p.rating.between(2, 4)); + +// Apollo Client +const { data } = await apolloClient.query({ + query: LIST_POSTS, + variables: { filter: { rating: { between: [2, 4] } } }, +}); +const midRated = data.listPosts.items.filter((p) => !p._deleted); +``` + + + +**Other operators** (`ne`, `gt`, `ge`, `lt`, `le`, `notContains`, `beginsWith`) follow the same pattern as `eq` above -- replace the operator name and value. See the filter operator mapping table for the complete syntax reference. + + + +**Combining conditions with `and`:** + +```ts +// DataStore +const posts = await DataStore.query(Post, (p) => + p.and((p) => [p.rating.gt(4), p.status.eq('PUBLISHED')]) +); + +// Apollo Client +const { data } = await apolloClient.query({ + query: LIST_POSTS, + variables: { + filter: { + and: [{ rating: { gt: 4 } }, { status: { eq: 'PUBLISHED' } }], + }, + }, +}); +``` + + + +Top-level filter fields are **implicitly AND-ed** in AppSync. This means `{ status: { eq: 'PUBLISHED' }, rating: { gt: 4 } }` is equivalent to using explicit `and`. Use explicit `and` when you need it nested inside an `or`. + + + +**Combining conditions with `or`:** + +```ts +const { data } = await apolloClient.query({ + query: LIST_POSTS, + variables: { + filter: { + or: [ + { title: { contains: 'React' } }, + { title: { contains: 'Apollo' } }, + ], + }, + }, +}); +``` + +**Negating with `not`:** + +```ts +const { data } = await apolloClient.query({ + query: LIST_POSTS, + variables: { + filter: { not: { status: { eq: 'DRAFT' } } }, + }, +}); +``` + +### The `in` and `notIn` workaround + + + +The `in` and `notIn` operators do **NOT** exist in AppSync's `ModelFilterInput` types. If you attempt to use `{ field: { in: [...] } }`, AppSync will reject the query with a validation error. + + + +**Replacing `in` with `or` + `eq`:** + +```ts +// DataStore: p.status.in(['PUBLISHED', 'DRAFT']) +// Apollo: combine multiple eq conditions with or +const { data } = await apolloClient.query({ + query: LIST_POSTS, + variables: { + filter: { + or: [{ status: { eq: 'PUBLISHED' } }, { status: { eq: 'DRAFT' } }], + }, + }, +}); +``` + + + +```ts +function buildInFilter(field: string, values: string[]) { + return { + or: values.map((value) => ({ [field]: { eq: value } })), + }; +} + +function buildNotInFilter(field: string, values: string[]) { + return { + and: values.map((value) => ({ [field]: { ne: value } })), + }; +} + +// Usage: +const { data } = await apolloClient.query({ + query: LIST_POSTS, + variables: { filter: buildInFilter('status', ['PUBLISHED', 'DRAFT']) }, +}); +``` + + + +## Pagination migration + +DataStore uses **page-based** pagination (zero-indexed `page` number + `limit`). AppSync uses **cursor-based** pagination (`nextToken` + `limit`). This is not a rename -- it is a fundamental semantic change. + +| Aspect | DataStore (Page-Based) | Apollo/AppSync (Cursor-Based) | +|--------|----------------------|-------------------------------| +| Navigation | Random access -- jump to any page | Sequential only -- must traverse pages in order | +| Parameters | `{ page: 0, limit: 10 }` | `{ limit: 10, nextToken: '...' }` | +| First page | `page: 0` | Omit `nextToken` (or pass `null`) | +| Next page | `page: page + 1` | Use `nextToken` from previous response | +| End detection | `items.length < limit` | `nextToken === null` | + +**Apollo Client cursor-based pagination:** + +```ts +// Page 1 (first 10 items) -- no nextToken needed +const { data: page1Data } = await apolloClient.query({ + query: LIST_POSTS, + variables: { limit: 10 }, +}); +const page1Items = page1Data.listPosts.items.filter((p) => !p._deleted); +const nextToken = page1Data.listPosts.nextToken; + +// Page 2 -- use nextToken from previous response +if (nextToken) { + const { data: page2Data } = await apolloClient.query({ + query: LIST_POSTS, + variables: { limit: 10, nextToken }, + }); +} +``` + + + +### Load More pattern (React) + +The most common pagination pattern with cursor-based pagination is "Load More" (infinite scroll): + +```tsx +import { useQuery } from '@apollo/client'; + +function PostList() { + const { data, loading, error, fetchMore } = useQuery(LIST_POSTS, { + variables: { limit: 10 }, + }); + + if (loading && !data) return

Loading...

; + if (error) return

Error: {error.message}

; + + const posts = (data?.listPosts?.items ?? []).filter((p) => !p._deleted); + const nextToken = data?.listPosts?.nextToken; + + const handleLoadMore = () => { + fetchMore({ + variables: { limit: 10, nextToken }, + updateQuery: (prev, { fetchMoreResult }) => { + if (!fetchMoreResult) return prev; + return { + listPosts: { + ...fetchMoreResult.listPosts, + items: [ + ...prev.listPosts.items, + ...fetchMoreResult.listPosts.items, + ], + }, + }; + }, + }); + }; + + return ( +
+
    + {posts.map((post) => ( +
  • {post.title}
  • + ))} +
+ +
+ ); +} +``` + +
+ + + +When using `nextToken` with filters, AppSync may return **fewer items than `limit`**. Always check `nextToken === null` to determine if more pages exist -- do **not** use `items.length < limit` as the end-of-results indicator. + + + +## Sorting migration + +DataStore supports `SortDirection.ASCENDING` and `SortDirection.DESCENDING`. AppSync's basic `listModels` query has **no `sortDirection` argument** by default. + +### Client-side sorting (recommended) + +For most use cases, fetch results and sort them in JavaScript: + +```ts +// DataStore +const posts = await DataStore.query(Post, Predicates.ALL, { + sort: (s) => s.createdAt(SortDirection.DESCENDING), +}); + +// Apollo Client +const { data } = await apolloClient.query({ query: LIST_POSTS }); +const posts = [...data.listPosts.items] + .filter((p) => !p._deleted) + .sort((a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ); +``` + + + +If your model has a Global Secondary Index (GSI) defined with the `@index` directive, AppSync generates a query with `sortDirection` support: + +```graphql +type Post @model { + id: ID! + title: String! + status: String! @index(name: "byStatus", sortKeyFields: ["createdAt"]) + createdAt: AWSDateTime! +} +``` + +This generates a `postsByStatus` query that accepts `sortDirection`: + +```ts +const LIST_POSTS_BY_STATUS = gql` + query PostsByStatus( + $status: String! + $sortDirection: ModelSortDirection + $limit: Int + $nextToken: String + ) { + postsByStatus( + status: $status + sortDirection: $sortDirection + limit: $limit + nextToken: $nextToken + ) { + items { ...PostDetails } + nextToken + } + } +`; + +const { data } = await apolloClient.query({ + query: LIST_POSTS_BY_STATUS, + variables: { status: 'PUBLISHED', sortDirection: 'DESC', limit: 10 }, +}); +``` + +Server-side sorting requires backend schema changes and only works when querying by the index's partition key. For general-purpose sorting, use client-side sorting. + + + +## Common mistakes + + + +### 1. Forgetting _version in update or delete mutations + +The most frequent migration error. DataStore handled `_version` internally. With Apollo, you must include it yourself. + +### 2. Using CREATE mutation for updates + +DataStore's `save()` handled both creates and updates. With Apollo, you must call the correct mutation. + +### 3. Not filtering _deleted records from list results + +DataStore automatically hid soft-deleted records. Apollo returns all records, including deleted ones. Always use `.filter(item => !item._deleted)` on list query results. + +### 4. Not using refetchQueries after mutations + +DataStore's local store automatically updated queries after mutations. Apollo's cache may not update list queries automatically. Add `refetchQueries: [{ query: LIST_POSTS }]` to mutations that affect list views. + +### 5. Using stale _version values + +If you cache a record's `_version` and another user or process updates the record, your mutation will fail. Re-query with `fetchPolicy: 'network-only'` before mutating when freshness is critical. + + diff --git a/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/migrate-relationships/index.mdx b/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/migrate-relationships/index.mdx new file mode 100644 index 00000000000..7f012aa1b05 --- /dev/null +++ b/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/migrate-relationships/index.mdx @@ -0,0 +1,507 @@ +import { getCustomStaticPath } from '@/utils/getCustomStaticPath'; + +export const meta = { + title: 'Migrate relationships', + description: 'Migrate DataStore hasMany, belongsTo, hasOne, and manyToMany relationships to Apollo Client with GraphQL selection sets.', + platforms: [ + 'angular', + 'javascript', + 'nextjs', + 'react', + 'react-native', + 'vue' + ] +}; + +export const getStaticPaths = async () => { + return getCustomStaticPath(meta.platforms); +}; + +export function getStaticProps(context) { + return { + props: { + platform: context.params.platform, + meta + } + }; +} + +Relationship handling is where DataStore and Apollo Client differ most fundamentally. DataStore **lazy-loads** relationships: you access a field and it fetches on demand, returning a Promise (for `belongsTo`/`hasOne`) or an AsyncCollection (for `hasMany`). Apollo Client **eagerly loads** relationships based on what you include in your GraphQL selection set. This gives you explicit control over data fetching granularity but requires you to think about what data you need upfront. + +## Schema reference + +All examples on this page use the following illustrative schema definitions from your Gen 1 backend: + +```graphql title="amplify/backend/api//schema.graphql" +type Post @model @auth(rules: [{ allow: owner }]) { + id: ID! + title: String! + content: String + status: String + rating: Int + comments: [Comment] @hasMany(indexName: "byPost", fields: ["id"]) + tags: [PostTag] @hasMany(indexName: "byPostTag", fields: ["id"]) + metadata: PostMetadata @hasOne(fields: ["id"]) +} + +type Comment @model @auth(rules: [{ allow: owner }]) { + id: ID! + content: String! + postID: ID! @index(name: "byPost") + post: Post @belongsTo(fields: ["postID"]) +} + +type Tag @model @auth(rules: [{ allow: owner }]) { + id: ID! + name: String! + posts: [PostTag] @hasMany(indexName: "byTag", fields: ["id"]) +} + +type PostTag @model @auth(rules: [{ allow: owner }]) { + id: ID! + postID: ID! @index(name: "byPostTag") + tagID: ID! @index(name: "byTag") + post: Post @belongsTo(fields: ["postID"]) + tag: Tag @belongsTo(fields: ["tagID"]) +} + +type PostMetadata @model @auth(rules: [{ allow: owner }]) { + id: ID! + postID: ID! @index(name: "byPost") + views: Int + likes: Int + post: Post @belongsTo(fields: ["postID"]) +} +``` + + + +All relationship examples include `_version`, `_deleted`, and `_lastChangedAt` fields in selections for conflict-resolution-enabled backends. See the [Set up Apollo Client](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/set-up-apollo/) page for details. + + + + + +**Gen 1 field casing reminder:** Gen 1 backends use uppercase ID suffixes (`postID`, `tagID`) -- see [Set up Apollo Client](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/set-up-apollo/) for details. Match your actual schema field names exactly; mismatches silently return `null`. + + + +## hasMany: Post to Comments + +A `hasMany` relationship means a parent record has zero or more child records. The key change: DataStore's `AsyncCollection` with `.toArray()` becomes a nested GraphQL selection with an `items` wrapper object. + +### DataStore (before) + +```ts +const post = await DataStore.query(Post, '123'); +const comments = await post.comments.toArray(); +// comments is Comment[] -- fetched on demand when you called .toArray() +``` + +### Apollo Client (after) -- eager loading (nested selection) + +Define a GraphQL query that includes the comments in the selection set: + +```ts +const GET_POST_WITH_COMMENTS = gql` + ${POST_DETAILS_FRAGMENT} + query GetPostWithComments($id: ID!) { + getPost(id: $id) { + ...PostDetails + comments { + items { + id + content + createdAt + _version + _deleted + _lastChangedAt + } + nextToken + } + } + } +`; + +const { data } = await apolloClient.query({ + query: GET_POST_WITH_COMMENTS, + variables: { id: '123' }, +}); + +const post = data.getPost; +const comments = data.getPost.comments.items.filter(c => !c._deleted); +``` + +The comments come back in the same response as the post -- no second request needed. + + + +Always filter `_deleted` records from nested `items` arrays. Soft-deleted child records are still returned by AppSync. + + + +### Apollo Client (after) -- lazy loading (separate query) + +If you do not always need comments, omit them from the initial query and fetch them separately when needed: + +```ts +const LIST_COMMENTS_BY_POST = gql` + query ListCommentsByPost($filter: ModelCommentFilterInput) { + listComments(filter: $filter) { + items { + id + content + createdAt + _version + _deleted + _lastChangedAt + } + nextToken + } + } +`; + +// Fetch comments for a specific post on demand +const { data } = await apolloClient.query({ + query: LIST_COMMENTS_BY_POST, + variables: { filter: { postID: { eq: '123' } } }, +}); +const comments = data.listComments.items.filter(c => !c._deleted); +``` + + + +**Over-fetching warning:** Use the nested selection (eager) pattern for data you always display together. Use the separate query (lazy) pattern for data that is optional or loaded on user action (for example, expanding a comments section). + + + + + +### React hook example + +```tsx +import { useQuery } from '@apollo/client'; + +function PostWithComments({ postId }: { postId: string }) { + const { data, loading, error } = useQuery(GET_POST_WITH_COMMENTS, { + variables: { id: postId }, + }); + + if (loading) return

Loading...

; + if (error) return

Error loading post.

; + + const post = data.getPost; + const comments = post.comments.items.filter(c => !c._deleted); + + return ( +
+

{post.title}

+

{post.content}

+

Comments ({comments.length})

+ {comments.map(comment => ( +
+

{comment.content}

+
+ ))} +
+ ); +} +``` + +
+ +## belongsTo: Comment to Post + +A `belongsTo` relationship means a child record references its parent. The key change: DataStore resolves the parent automatically via a Promise. Apollo uses a nested selection to include the parent in the response. + +### DataStore (before) + +```ts +const comment = await DataStore.query(Comment, 'abc'); +const post = await comment.post; // Promise resolves to the parent Post +``` + +### Apollo Client (after) + +```ts +const GET_COMMENT_WITH_POST = gql` + query GetCommentWithPost($id: ID!) { + getComment(id: $id) { + id + content + post { + id + title + status + _version + _deleted + _lastChangedAt + } + _version + _deleted + _lastChangedAt + } + } +`; + +const { data } = await apolloClient.query({ + query: GET_COMMENT_WITH_POST, + variables: { id: 'abc' }, +}); + +const comment = data.getComment; +const post = comment.post; // Parent Post is already loaded -- no extra request +``` + +The parent object is directly available as a nested field. No Promise, no `.then()` -- it is already resolved in the response. + + + +The foreign key field (`postID`) is also available on the Comment if you only need the parent's ID without fetching the full parent record. + + + +## hasOne: Post to PostMetadata + +A `hasOne` relationship represents 1:1 ownership. Similar to `belongsTo` -- DataStore returns a Promise, Apollo uses a nested selection. The result is `null` if no related record exists. + +### DataStore (before) + +```ts +const post = await DataStore.query(Post, '123'); +const metadata = await post.metadata; // Promise resolves to PostMetadata or undefined +``` + +### Apollo Client (after) + +```ts +const GET_POST_WITH_METADATA = gql` + ${POST_DETAILS_FRAGMENT} + query GetPostWithMetadata($id: ID!) { + getPost(id: $id) { + ...PostDetails + metadata { + id + views + likes + _version + _deleted + _lastChangedAt + } + } + } +`; + +const { data } = await apolloClient.query({ + query: GET_POST_WITH_METADATA, + variables: { id: '123' }, +}); + +const post = data.getPost; +const metadata = post.metadata; // PostMetadata object or null +``` + +## manyToMany: Post and Tag + +Many-to-many relationships use an explicit join table model. Posts and Tags are connected through the `PostTag` join model. The key change: instead of getting tags directly, you query `PostTag` join records and then extract the `tag` from each one. + +### DataStore (before) + +```ts +const post = await DataStore.query(Post, '123'); +const postTags = await post.tags.toArray(); +const tags = await Promise.all(postTags.map(pt => pt.tag)); +``` + +### Apollo Client (after) -- querying tags for a post + +```ts +const GET_POST_WITH_TAGS = gql` + ${POST_DETAILS_FRAGMENT} + query GetPostWithTags($id: ID!) { + getPost(id: $id) { + ...PostDetails + tags { + items { + id + tag { + id + name + _version + _deleted + _lastChangedAt + } + _version + _deleted + } + } + } + } +`; + +const { data } = await apolloClient.query({ + query: GET_POST_WITH_TAGS, + variables: { id: '123' }, +}); + +// Extract tags from the join records, filtering out deleted join entries +const tags = data.getPost.tags.items + .filter(pt => !pt._deleted) + .map(pt => pt.tag); +``` + + + +Filter `_deleted` on the **join records** (`PostTag`), not just the tags themselves. A deleted join record means the association was removed even if the Tag still exists. + + + +### Create a many-to-many association + +To associate a Post with a Tag, create a `PostTag` join record: + +```ts +const CREATE_POST_TAG = gql` + mutation CreatePostTag($input: CreatePostTagInput!) { + createPostTag(input: $input) { + id + postID + tagID + _version + _deleted + _lastChangedAt + } + } +`; + +await apolloClient.mutate({ + mutation: CREATE_POST_TAG, + variables: { input: { postID: '123', tagID: '456' } }, +}); +``` + +### Remove a many-to-many association + +To remove an association, delete the `PostTag` join record (you need its `id` and `_version`): + +```ts +const DELETE_POST_TAG = gql` + mutation DeletePostTag($input: DeletePostTagInput!) { + deletePostTag(input: $input) { + id + _version + } + } +`; + +await apolloClient.mutate({ + mutation: DELETE_POST_TAG, + variables: { + input: { + id: postTagRecord.id, + _version: postTagRecord._version, + }, + }, +}); +``` + +Deleting the `PostTag` join record removes the association between the Post and Tag. It does **not** delete the Post or the Tag themselves. + +## Create related records + +When creating a child record that belongs to a parent, the key difference is how you specify the relationship. + +**DataStore (before):** DataStore accepted the model instance for the relationship: + +```ts +const existingPost = await DataStore.query(Post, '123'); +await DataStore.save( + new Comment({ + content: 'Great post!', + post: existingPost, // Pass the model instance + }) +); +``` + +**Apollo Client (after):** Apollo requires the foreign key ID, not the model instance: + +```ts +const CREATE_COMMENT = gql` + mutation CreateComment($input: CreateCommentInput!) { + createComment(input: $input) { + id + content + postID + _version + _deleted + _lastChangedAt + } + } +`; + +await apolloClient.mutate({ + mutation: CREATE_COMMENT, + variables: { + input: { + content: 'Great post!', + postID: '123', // Pass the foreign key ID directly + }, + }, +}); +``` + +## Quick reference table + +| Relationship | DataStore Access Pattern | Apollo Client Access Pattern | Key Change | +|---|---|---|---| +| **hasMany** (Post to Comments) | `await post.comments.toArray()` | Nested `comments { items { ... } }` selection | AsyncCollection becomes `items` wrapper; eager-loaded in single request | +| **belongsTo** (Comment to Post) | `await comment.post` | Nested `post { ... }` selection | Promise becomes nested object; no await needed | +| **hasOne** (Post to Metadata) | `await post.metadata` | Nested `metadata { ... }` selection | Promise becomes nested object or `null` | +| **manyToMany** (Post to Tag) | `await post.tags.toArray()` then `await pt.tag` | Nested `tags { items { tag { ... } } }` selection | Must query through join table; filter `_deleted` on join records | +| **Creating children** | `new Comment({ post: existingPost })` | `{ input: { postID: '123' } }` | Model instance becomes foreign key ID | + +## Performance considerations + +### Eager vs. lazy loading + +DataStore always lazy-loaded relationships. Apollo gives you the choice: + +- **Eager loading** (nested selection): Fetches related data in the same GraphQL request. Use this for data you always display together. +- **Lazy loading** (separate query): Fetches related data only when needed. Use this for data that is optional or loaded on user action. + +### The N+1 query problem + +DataStore hid the N+1 problem because all data was local -- lazy-loading from IndexedDB was effectively free. With Apollo, each separate query is a network request: + +```ts +// BAD: N+1 -- separate query for each post's comments +const { data } = await apolloClient.query({ query: LIST_POSTS }); +for (const post of data.listPosts.items) { + await apolloClient.query({ + query: LIST_COMMENTS_BY_POST, + variables: { filter: { postID: { eq: post.id } } }, + }); +} + +// GOOD: include comments in the list query +const LIST_POSTS_WITH_COMMENTS = gql` + query ListPostsWithComments($filter: ModelPostFilterInput, $limit: Int) { + listPosts(filter: $filter, limit: $limit) { + items { + ...PostDetails + comments { + items { id content _version _deleted } + } + } + nextToken + } + } +`; +``` + +### Recommendations + +1. **Use nested selections** for data you always need together. One request is always faster than multiple. +2. **Use separate queries** for optional or on-demand data. +3. **Be mindful of depth.** Limit nesting to 2-3 levels to avoid large response sizes. +4. **Apollo's cache helps.** Once a related record is fetched, Apollo caches it by `__typename` and `id`. Subsequent queries for the same record may resolve from cache. diff --git a/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/set-up-apollo/index.mdx b/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/set-up-apollo/index.mdx new file mode 100644 index 00000000000..c0710658e05 --- /dev/null +++ b/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/set-up-apollo/index.mdx @@ -0,0 +1,767 @@ +import { getCustomStaticPath } from '@/utils/getCustomStaticPath'; + +export const meta = { + title: 'Set up Apollo Client', + description: 'Install Apollo Client, configure authentication with Cognito, set up error handling and retry logic, configure real-time subscriptions with Amplify, and write GraphQL operations for your AppSync backend.', + platforms: [ + 'angular', + 'javascript', + 'nextjs', + 'react', + 'react-native', + 'vue' + ] +}; + +export const getStaticPaths = async () => { + return getCustomStaticPath(meta.platforms); +}; + +export function getStaticProps(context) { + return { + props: { + platform: context.params.platform, + meta + } + }; +} + +This page covers everything you need to get Apollo Client working with your AppSync endpoint: prerequisites, installing Apollo Client, writing GraphQL operations, understanding `_version` metadata, configuring the link chain for authentication and error handling, and setting up real-time subscriptions with Amplify. + +## Before you begin + +Before starting the migration, make sure you have: + +- An existing **Amplify Gen 1 backend** with your data models deployed and working +- Your Amplify configuration file (`amplifyconfiguration.json` or `aws-exports.js`) in your project +- The `aws-amplify` v6 package installed and configured (`Amplify.configure(config)` called at app startup) +- Familiarity with **GraphQL syntax** -- queries, mutations, and subscriptions + + + +**This guide covers the frontend migration first.** You will replace the DataStore client library with Apollo Client while your Gen 1 backend stays in place. The `aws-amplify` v6 library works with Gen 1 backends -- you only need to update how you call the API. After completing the frontend migration and [disabling conflict resolution](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/disable-conflict-resolution/), you can proceed to migrate your backend to Amplify Gen 2. + + + +## Install Apollo Client + +Install Apollo Client: + +```bash +npm install @apollo/client@^3.14.0 +``` + +You do **not** need to install `graphql` separately -- it is already provided by `aws-amplify`. Installing `graphql` explicitly would cause npm to resolve a newer version (v16), which conflicts with `aws-amplify`'s pinned `graphql@15.8.0` and fails with an `ERESOLVE` error. + + + +**Why Apollo Client v3 (not v4)?** The `apollo3-cache-persist` library -- needed if you choose to [add local caching](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/add-local-caching/) later -- only supports Apollo Client v3. Starting with v3 avoids a disruptive version migration mid-project. Using `@apollo/client@^3.14.0` ensures you get the latest v3 release with all stability fixes. + + + +## Find your GraphQL endpoint + +Your GraphQL endpoint and auth configuration are in `aws-exports.js` (or `amplifyconfiguration.json`): + +```json +{ + "aws_appsync_graphqlEndpoint": "https://xxxxx.appsync-api.us-east-1.amazonaws.com/graphql", + "aws_appsync_authenticationType": "AMAZON_COGNITO_USER_POOLS", + "aws_appsync_region": "us-east-1" +} +``` + +You will use the `aws_appsync_graphqlEndpoint` value when configuring Apollo Client. + + + +**Gen 1 field name casing.** Gen 1 backends generate foreign key fields with **uppercase** ID suffixes (e.g., `postID`, `tagID`), while Gen 2 uses lowercase (`postId`, `tagId`). The code examples in this guide use the Gen 2 lowercase convention. **If your backend still uses Gen 1, you must adjust all field names in GraphQL operations to match your actual schema.** A mismatched field name does not produce an error -- AppSync silently returns `null`, making this extremely difficult to debug. Check your `src/graphql/queries.js` or the AppSync console's Schema tab to verify the correct casing for every foreign key field before writing any operations. + + + +## Generate typed operations (optional) + +Your Gen 1 project already has auto-generated GraphQL operations in `src/graphql/` (queries, mutations, subscriptions). These operations continue to work with your Gen 1 backend -- you can reference them when writing the Apollo Client operations below. + +Alternatively, you can regenerate them or copy queries, mutations, and subscriptions directly from the **AWS AppSync console** by navigating to the Schema tab and the Queries tab. + +For full details on integrating generated types with Apollo Client, see the [Advanced patterns](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/advanced-patterns/) page. + +## Write GraphQL operations + +Apollo Client uses `gql` tagged template literals to define GraphQL operations. This section shows the standard patterns using a `Post` model as the running example. + +### GraphQL fragment for reusable field selection + +Fragments let you define a reusable set of fields. Every operation references this fragment, ensuring consistent field selection across your app: + +```graphql +fragment PostDetails on Post { + id + title + content + status + rating + createdAt + updatedAt + _version + _deleted + _lastChangedAt + owner +} +``` + +If your model uses **owner-based authorization** (`@auth(rules: [{ allow: owner }])`), include the `owner` field in your fragments. This field is needed for owner-scoped subscriptions. + + + +The `_version`, `_deleted`, and `_lastChangedAt` fields are required for backends with conflict resolution enabled. If your app used DataStore, your backend has conflict resolution enabled. See the [_version metadata section](#understand-_version-metadata) below. + + + +### Complete operation definitions + +```ts title="src/graphql/operations.ts" +import { gql } from '@apollo/client'; + +// Fragment for consistent field selection +const POST_DETAILS_FRAGMENT = gql` + fragment PostDetails on Post { + id + title + content + status + rating + createdAt + updatedAt + _version + _deleted + _lastChangedAt + owner + } +`; + +// List all posts +export const LIST_POSTS = gql` + ${POST_DETAILS_FRAGMENT} + query ListPosts($filter: ModelPostFilterInput, $limit: Int, $nextToken: String) { + listPosts(filter: $filter, limit: $limit, nextToken: $nextToken) { + items { + ...PostDetails + } + nextToken + } + } +`; + +// Get a single post by ID +export const GET_POST = gql` + ${POST_DETAILS_FRAGMENT} + query GetPost($id: ID!) { + getPost(id: $id) { + ...PostDetails + } + } +`; + +// Create a new post +export const CREATE_POST = gql` + ${POST_DETAILS_FRAGMENT} + mutation CreatePost($input: CreatePostInput!) { + createPost(input: $input) { + ...PostDetails + } + } +`; + +// Update an existing post +export const UPDATE_POST = gql` + ${POST_DETAILS_FRAGMENT} + mutation UpdatePost($input: UpdatePostInput!) { + updatePost(input: $input) { + ...PostDetails + } + } +`; + +// Delete a post +export const DELETE_POST = gql` + ${POST_DETAILS_FRAGMENT} + mutation DeletePost($input: DeletePostInput!) { + deletePost(input: $input) { + ...PostDetails + } + } +`; +``` + +Every operation -- including mutations -- returns the full `PostDetails` fragment. This ensures you always have the latest `_version` value for subsequent mutations. + + + +**Multi-model apps:** Define a result interface for **each model** in your app. Co-locate them with the operations in a single `src/graphql/operations.ts` file (or split into `operations/post.ts`, `operations/comment.ts`, etc. for larger schemas). Your auto-generated `src/graphql/queries.js` and `src/graphql/mutations.js` files contain the exact field names and operation signatures for every model -- use them as a reference when writing your typed operations. + + + + + +**Replacing DataStore enums:** DataStore model files export TypeScript `enum` types (e.g., `PostStatus`). After migration, you no longer import from `./models`, so you need to define these values yourself. If your TypeScript configuration has `erasableSyntaxOnly: true` (the default in TypeScript 5.9+ and Vite 8 scaffolds -- note that Vite 8 sets this in `tsconfig.app.json`, not `tsconfig.json`), `enum` declarations are not allowed because they emit runtime code. Use a `const` object with `as const` instead: + +```ts +// Instead of: enum PostStatus { DRAFT = 'DRAFT', PUBLISHED = 'PUBLISHED' } +const PostStatus = { DRAFT: 'DRAFT', PUBLISHED: 'PUBLISHED', ARCHIVED: 'ARCHIVED' } as const; +type PostStatus = (typeof PostStatus)[keyof typeof PostStatus]; +``` + +Also note: Vite 8 scaffolds set `verbatimModuleSyntax: true` in `tsconfig.app.json`. This requires using `import type` for type-only imports (e.g., `import type { TypedDocumentNode } from '@apollo/client'` instead of `import { TypedDocumentNode }`). + + + +## Understand _version metadata + +This is one of the most important sections in this guide. If your app used DataStore, your backend **has conflict resolution enabled**, and you must handle three metadata fields correctly or your mutations will fail. + +### Why these fields exist + +DataStore enables **conflict resolution** on the AppSync backend via DynamoDB. This mechanism adds three metadata fields to every model: + +| Field | Type | Purpose | +|-------|------|---------| +| `_version` | `Int` | Optimistic locking counter. Incremented on every successful mutation. | +| `_deleted` | `Boolean` | Soft-delete flag. When `true`, the record is logically deleted but still exists in DynamoDB. | +| `_lastChangedAt` | `AWSTimestamp` | Millisecond timestamp of the last change. Set automatically by AppSync. | + +### When you need them + +- **All mutations require `_version`** in the input (except creates). Omitting it causes a `ConditionalCheckFailedException`. +- **All queries should select** `_version`, `_deleted`, and `_lastChangedAt` in the response fields. +- **List queries return soft-deleted records.** You must filter them out in your application code. + +### How to handle them + +Follow these three rules: + +**1. Always include metadata fields in response selections.** Every query and mutation response should include `_version`, `_deleted`, and `_lastChangedAt` (the `PostDetails` fragment above does this). + +**2. Always pass `_version` from the last query result into mutation inputs:** + +```ts +// First, query the current post (includes _version in response) +const { data } = await apolloClient.query({ + query: GET_POST, + variables: { id: postId }, +}); +const post = data.getPost; + +// Then, pass _version when updating +await apolloClient.mutate({ + mutation: UPDATE_POST, + variables: { + input: { + id: post.id, + title: 'Updated Title', + _version: post._version, // REQUIRED + }, + }, +}); +``` + +**3. Filter soft-deleted records from list query results:** + +```ts +const { data } = await apolloClient.query({ query: LIST_POSTS }); +const activePosts = data.listPosts.items.filter(post => !post._deleted); +``` + +### Helper: filter soft-deleted records + +A simple utility function to filter out soft-deleted records from any list query: + +```ts title="src/utils/filterDeleted.ts" +function filterDeleted(items: T[]): T[] { + return items.filter(item => !item._deleted); +} + +// Usage +const { data } = await apolloClient.query({ query: LIST_POSTS }); +const activePosts = filterDeleted(data.listPosts.items); +``` + +## Configure Apollo Client + +Apollo Client communicates with AppSync through a **link chain** -- a series of middleware functions that process each request. You will build four links: + +1. **HTTP Link** -- sends the actual GraphQL request to AppSync +2. **Auth Link** -- injects your Cognito ID token into each request +3. **Error Link** -- intercepts and logs GraphQL and network errors +4. **Retry Link** -- automatically retries failed network requests with backoff + +### The HTTP link + +```ts +import { createHttpLink } from '@apollo/client'; +import config from '../amplifyconfiguration.json'; + +const httpLink = createHttpLink({ + uri: config.aws_appsync_graphqlEndpoint, +}); +``` + + + +**Do NOT use `BatchHttpLink`.** AppSync does not support HTTP request batching. Batched requests will fail silently, returning errors for all operations in the batch. + + + +### The auth link + +The auth link injects your Cognito User Pools ID token into every request: + +```ts +import { setContext } from '@apollo/client/link/context'; +import { fetchAuthSession } from 'aws-amplify/auth'; + +const authLink = setContext(async (_, { headers }) => { + try { + const session = await fetchAuthSession(); + const token = session.tokens?.idToken?.toString(); + return { + headers: { + ...headers, + authorization: token || '', + }, + }; + } catch (error) { + console.error('Auth session error:', error); + return { headers }; + } +}); +``` + +`fetchAuthSession()` is called on every request, ensuring tokens are always fresh. Amplify automatically refreshes expired access tokens using the refresh token. + +### The error link + +The error link intercepts all GraphQL and network errors globally: + +```ts +import { onError } from '@apollo/client/link/error'; + +const errorLink = onError(({ graphQLErrors, networkError }) => { + if (graphQLErrors) { + for (const { message, locations, path } of graphQLErrors) { + console.error( + `[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}` + ); + + if (message.includes('Unauthorized') || message.includes('401')) { + // Token expired or invalid -- redirect to sign-in + } + } + } + + if (networkError) { + console.error(`[Network error]: ${networkError}`); + } +}); +``` + +**Common AppSync errors:** + +| Error Message | Cause | Action | +|---------------|-------|--------| +| `Unauthorized` or `401` | Expired or missing auth token | Redirect to sign-in | +| `ConditionalCheckFailedException` | Missing or stale `_version` in mutation input | Re-query to get latest `_version`, then retry | +| `ConflictUnhandled` | Conflict resolution rejected the mutation | Re-query and retry with fresh data | +| `Network error` | Connectivity issue | Retry link handles this automatically | + +### The retry link + +```ts +import { RetryLink } from '@apollo/client/link/retry'; + +const retryLink = new RetryLink({ + delay: { + initial: 300, + max: 5000, + jitter: true, + }, + attempts: { + max: 3, + retryIf: (error) => !!error, + }, +}); +``` + +Retries up to 3 times on any network error with exponential backoff. The `jitter: true` setting adds randomness to prevent thundering herd problems. + +### Put it all together + +Combine all four links into a single Apollo Client instance: + +```ts title="src/apolloClient.ts" +import { + ApolloClient, + InMemoryCache, + createHttpLink, + from, +} from '@apollo/client'; + +export const apolloClient = new ApolloClient({ + link: from([retryLink, errorLink, authLink, httpLink]), + cache: new InMemoryCache(), +}); +``` + +### Link chain order + +The `from()` function composes links **left to right** on outgoing requests and **right to left** on incoming responses: + +``` +Request --> RetryLink --> ErrorLink --> AuthLink --> HttpLink --> AppSync +Response <-- RetryLink <-- ErrorLink <-- AuthLink <-- HttpLink <-- AppSync +``` + +- **RetryLink is first** -- it wraps the entire chain, so if any downstream link or the network request fails, RetryLink can re-execute the full chain (including re-fetching the auth token) +- **ErrorLink is second** -- it sees all errors and can log or redirect +- **AuthLink is third** -- it injects the Cognito token right before the HTTP request +- **HttpLink is last** -- it sends the actual request to AppSync + + + +### Connect to React + +Wrap your application with `ApolloProvider` to make the client available to all components: + +```tsx title="src/App.tsx" +import { ApolloProvider } from '@apollo/client'; +import { apolloClient } from './apolloClient'; + +function App() { + return ( + + {/* Your app components can now use useQuery, useMutation, etc. */} + + ); +} +``` + +Any component inside `ApolloProvider` can use Apollo's React hooks (`useQuery`, `useMutation`) to interact with your AppSync API. + + + +## Sign-out and cache cleanup + +When a user signs out, you must clear Apollo Client's in-memory cache to prevent the next user from seeing stale data: + +```ts title="src/auth.ts" +import { signOut } from 'aws-amplify/auth'; +import { apolloClient } from './apolloClient'; + +async function handleSignOut() { + // 1. Clear Apollo Client's in-memory cache + await apolloClient.clearStore(); + + // 2. Sign out from Amplify (clears Cognito tokens) + await signOut(); +} +``` + + + +**Name the function `handleSignOut` (not `signOut`)** to avoid shadowing the Amplify import. Naming it `signOut` creates a recursive call -- the function calls itself instead of Amplify's `signOut`, causing a stack overflow. + + + +**Key details:** + +- **`clearStore()`** clears the in-memory cache and cancels all active queries. Use `resetStore()` instead if you want to clear the cache **and** refetch all active queries. +- **Order matters:** Clear the cache first, then sign out. If you sign out first, `clearStore()` may trigger refetches that fail because the auth token is already invalidated. +- **If you add local caching** (covered on the [Add local caching](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/add-local-caching/) page), the sign-out function will also need to purge the persistent cache. + + + +Here is the full `src/apolloClient.ts` file combining everything above: + +```ts title="src/apolloClient.ts" +import { + ApolloClient, + InMemoryCache, + createHttpLink, + from, +} from '@apollo/client'; +import { setContext } from '@apollo/client/link/context'; +import { onError } from '@apollo/client/link/error'; +import { RetryLink } from '@apollo/client/link/retry'; +import { fetchAuthSession } from 'aws-amplify/auth'; +import config from '../amplifyconfiguration.json'; + +// --- HTTP Link --- +const httpLink = createHttpLink({ + uri: config.aws_appsync_graphqlEndpoint, +}); + +// --- Auth Link --- +const authLink = setContext(async (_, { headers }) => { + try { + const session = await fetchAuthSession(); + const token = session.tokens?.idToken?.toString(); + return { + headers: { + ...headers, + authorization: token || '', + }, + }; + } catch (error) { + console.error('Auth session error:', error); + return { headers }; + } +}); + +// --- Error Link --- +const errorLink = onError(({ graphQLErrors, networkError }) => { + if (graphQLErrors) { + for (const { message, locations, path } of graphQLErrors) { + console.error( + `[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}` + ); + + if (message.includes('Unauthorized') || message.includes('401')) { + // Token expired or invalid -- redirect to sign-in + } + } + } + + if (networkError) { + console.error(`[Network error]: ${networkError}`); + } +}); + +// --- Retry Link --- +const retryLink = new RetryLink({ + delay: { initial: 300, max: 5000, jitter: true }, + attempts: { max: 3, retryIf: (error) => !!error }, +}); + +// --- Apollo Client --- +// Link chain: RetryLink -> ErrorLink -> AuthLink -> HttpLink -> AppSync +export const apolloClient = new ApolloClient({ + link: from([retryLink, errorLink, authLink, httpLink]), + cache: new InMemoryCache(), +}); +``` + + + +## Set up real-time subscriptions + +Subscriptions use the Amplify library (not Apollo) because AppSync uses a custom WebSocket protocol that standard GraphQL subscription libraries cannot handle. + + + +**Do NOT use `graphql-ws`, `subscriptions-transport-ws`, or Apollo's `WebSocketLink` with AppSync.** These libraries do not speak AppSync's custom WebSocket protocol and will fail silently -- the WebSocket connection establishes successfully but subscription callbacks never fire. + + + +### Create the Amplify subscription client + +Create the Amplify client alongside your Apollo Client. You should already have Amplify configured at app startup: + +```ts +import { generateClient } from 'aws-amplify/api'; + +const amplifyClient = generateClient(); +``` + +You now have two clients: +- **`apolloClient`** -- for queries, mutations, and caching +- **`amplifyClient`** -- for subscriptions only + + + +**Tip:** Your auto-generated `src/graphql/subscriptions.js` file contains the exact subscription signatures for your schema, including any `$owner` and `$filter` parameters. Reference this file to verify the correct field names and available arguments for each subscription type before writing your own. + + + +### Subscription pattern: refetch on event (recommended) + +The simplest and most reliable approach: when a subscription event fires, refetch the list query from the server. + + + +```tsx +import { useQuery } from '@apollo/client'; +import { generateClient } from 'aws-amplify/api'; +import { useEffect } from 'react'; +import { LIST_POSTS } from './graphql/operations'; + +const amplifyClient = generateClient(); + +function PostList() { + const { data, loading, error, refetch } = useQuery(LIST_POSTS); + + useEffect(() => { + const subscriptions = [ + amplifyClient.graphql({ + query: `subscription OnCreatePost { + onCreatePost { id } + }` + }).subscribe({ + next: () => refetch(), + error: (err) => console.error('Create subscription error:', err), + }), + amplifyClient.graphql({ + query: `subscription OnUpdatePost { + onUpdatePost { id } + }` + }).subscribe({ + next: () => refetch(), + error: (err) => console.error('Update subscription error:', err), + }), + amplifyClient.graphql({ + query: `subscription OnDeletePost { + onDeletePost { id } + }` + }).subscribe({ + next: () => refetch(), + error: (err) => console.error('Delete subscription error:', err), + }), + ]; + + return () => subscriptions.forEach(sub => sub.unsubscribe()); + }, [refetch]); + + if (loading) return
Loading...
; + if (error) return
Error: {error.message}
; + + const activePosts = data?.listPosts?.items?.filter( + (post) => !post._deleted + ) || []; + + return ( +
    + {activePosts.map((post) => ( +
  • {post.title}
  • + ))} +
+ ); +} +``` + +
+ +**Why this pattern works well:** + +- The subscription payload only needs `id` since you are refetching the full list anyway, keeping the subscription lightweight +- No cache manipulation logic to get wrong -- the refetch guarantees consistency with the server +- One extra network round-trip per event, which is typically under 100ms and imperceptible for most applications + + + +For applications that need lower latency or handle high-frequency updates, you can update Apollo's cache directly from subscription data instead of refetching. This avoids the extra network round-trip but requires more code and careful cache management. + +```ts +import { useQuery } from '@apollo/client'; +import { generateClient } from 'aws-amplify/api'; +import { useEffect } from 'react'; +import { LIST_POSTS, POST_DETAILS_FRAGMENT } from './graphql/queries'; +import { apolloClient } from './apolloClient'; + +const amplifyClient = generateClient(); + +function PostListAdvanced() { + const { data, loading, error } = useQuery(LIST_POSTS); + + useEffect(() => { + const sub = amplifyClient.graphql({ + query: `subscription OnCreatePost { + onCreatePost { + id title content status rating + _version _deleted _lastChangedAt + createdAt updatedAt + } + }` + }).subscribe({ + next: ({ data }) => { + const newPost = data.onCreatePost; + apolloClient.cache.modify({ + fields: { + listPosts(existingData = { items: [] }) { + const newRef = apolloClient.cache.writeFragment({ + data: newPost, + fragment: POST_DETAILS_FRAGMENT, + }); + return { + ...existingData, + items: [...existingData.items, newRef], + }; + }, + }, + }); + }, + error: (err) => console.error('Create subscription error:', err), + }); + + return () => sub.unsubscribe(); + }, []); + + // ... render logic +} +``` + +**Recommendation:** Start with the refetch pattern. Only move to direct cache updates if you have measured a performance problem. + + + +### DataStore comparison + +| DataStore | Amplify + Apollo (Hybrid) | +|-----------|--------------------------| +| `DataStore.observe(Post).subscribe(...)` | `amplifyClient.graphql({ query: onCreatePost }).subscribe(...)` | +| `DataStore.observeQuery(Post)` | `useQuery(LIST_POSTS)` + subscription refetch | +| Automatic per-model subscriptions | Manual setup per subscription type (create, update, delete) | +| Single observe call for all event types | Separate subscription per event type | + +### Troubleshooting subscriptions + + + +**Subscription connects but never fires:** + +The subscription name must match your schema exactly. AppSync subscriptions are generated as `onCreateModelName`, `onUpdateModelName`, and `onDeleteModelName` (camelCase). Check your AppSync schema in the AWS console. + +**Auth error on subscription:** + +Amplify must be configured **before** creating the subscription client. Make sure `Amplify.configure(config)` runs at app startup before any call to `generateClient()`. + +**Subscription disconnects after ~5 minutes of inactivity:** + +This is normal behavior. Amplify's `AWSAppSyncRealTimeProvider` handles automatic reconnection without any action on your part. + +**Subscription works in development but not in production:** + +Check that your `amplifyconfiguration.json` (or `aws-exports.js`) configuration is correct for the production environment and that CORS is configured on your AppSync API to allow WebSocket connections from your production domain. + +**Subscription connects but receives no events (owner-based auth):** + +If your model uses **owner-based authorization** (`@auth(rules: [{ allow: owner }])`), you must pass the `$owner` variable in your subscriptions. Without it, the subscription connects successfully but AppSync silently filters out all events. This is the **most common cause** of "subscriptions work but nothing happens." + +Get the owner value from the current auth session and pass it as a variable: + +```ts +import { fetchAuthSession } from 'aws-amplify/auth'; + +const session = await fetchAuthSession(); +const owner = session.tokens?.idToken?.payload?.sub as string; + +(amplifyClient.graphql({ + query: `subscription OnCreatePost($owner: String!) { + onCreatePost(owner: $owner) { id } + }`, + variables: { owner }, +}) as any).subscribe({ next: () => refetch() }); +``` + +All three subscription types (`onCreate`, `onUpdate`, `onDelete`) need the `$owner` variable for owner-based auth models. See the [Advanced patterns](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/advanced-patterns/) page for the complete React component pattern. + + From b3c69f5a40261e07e06b5f9e0dd29e9cd4165cf6 Mon Sep 17 00:00:00 2001 From: Jonas Greifenhain <51089187+cadivus@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:21:32 +0200 Subject: [PATCH 2/4] docs: datastore migration guide for mobile (#8580) * docs: Add Flutter guide * docs: Don't mention Apollo in the backend part * docs: Add Android guide * docs: Add Swift guide * docs: Update spellcheck --- cspell.json | 7 +- src/directory/directory.mjs | 31 +- .../authorization-and-data/index.mdx | 52 ++ .../disable-conflict-resolution/index.mdx | 5 +- .../helpful-resources/index.mdx | 131 +++++ .../migrate-from-datastore/index.mdx | 221 ++++++++- .../local-caching/index.mdx | 310 ++++++++++++ .../migrate-datastore-to-apollo/index.mdx | 315 ++++++++++++ .../migrate-to-api/index.mdx | 462 ++++++++++++++++++ .../offline-first/index.mdx | 393 +++++++++++++++ .../remove-datastore/index.mdx | 231 +++++++++ .../schema-and-operations/index.mdx | 222 +++++++++ .../set-up-apollo-ios/index.mdx | 358 ++++++++++++++ .../set-up-apollo-kotlin/index.mdx | 254 ++++++++++ 14 files changed, 2987 insertions(+), 5 deletions(-) create mode 100644 src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/authorization-and-data/index.mdx create mode 100644 src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/helpful-resources/index.mdx create mode 100644 src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/local-caching/index.mdx create mode 100644 src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/migrate-datastore-to-apollo/index.mdx create mode 100644 src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/migrate-to-api/index.mdx create mode 100644 src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/offline-first/index.mdx create mode 100644 src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/remove-datastore/index.mdx create mode 100644 src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/schema-and-operations/index.mdx create mode 100644 src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/set-up-apollo-ios/index.mdx create mode 100644 src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/set-up-apollo-kotlin/index.mdx diff --git a/cspell.json b/cspell.json index f33889991db..c43dce26b34 100644 --- a/cspell.json +++ b/cspell.json @@ -1650,7 +1650,12 @@ "posttags", "callouts", "immer", - "ERESOLVE" + "ERESOLVE", + "graphqls", + "swiftpm", + "xcshareddata", + "unsynced", + "vararg" ], "flagWords": ["hte", "full-stack", "Full-stack", "Full-Stack", "sudo"], "patterns": [ diff --git a/src/directory/directory.mjs b/src/directory/directory.mjs index a3a92b68720..35465279837 100644 --- a/src/directory/directory.mjs +++ b/src/directory/directory.mjs @@ -1908,12 +1908,41 @@ export const directory = { { path: 'src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/add-local-caching/index.mdx' }, - { path: 'src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/advanced-patterns/index.mdx' }, + { + path: 'src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/remove-datastore/index.mdx' + }, + { + path: 'src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/schema-and-operations/index.mdx' + }, + { + path: 'src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/set-up-apollo-kotlin/index.mdx' + }, + { + path: 'src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/set-up-apollo-ios/index.mdx' + }, + { + path: 'src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/migrate-datastore-to-apollo/index.mdx' + }, + { + path: 'src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/migrate-to-api/index.mdx' + }, + { + path: 'src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/local-caching/index.mdx' + }, + { + path: 'src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/offline-first/index.mdx' + }, + { + path: 'src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/authorization-and-data/index.mdx' + }, { path: 'src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/disable-conflict-resolution/index.mdx' + }, + { + path: 'src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/helpful-resources/index.mdx' } ] } diff --git a/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/authorization-and-data/index.mdx b/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/authorization-and-data/index.mdx new file mode 100644 index 00000000000..9b765b25909 --- /dev/null +++ b/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/authorization-and-data/index.mdx @@ -0,0 +1,52 @@ +import { getCustomStaticPath } from '@/utils/getCustomStaticPath'; + +export const meta = { + title: 'Authorization and data handling', + description: 'Manage authorization rules and handle existing customer data during the migration from DataStore to Amplify API for Flutter.', + platforms: [ + 'flutter' + ] +}; + +export const getStaticPaths = async () => { + return getCustomStaticPath(meta.platforms); +}; + +export function getStaticProps(context) { + return { + props: { + platform: context.params.platform, + meta + } + }; +} + +## Authorization rules + +As part of the transition from Amplify DataStore, it is essential to understand how to manage authorization rules for your models using AWS AppSync, which continues to serve as the source of truth for all auth rules. While DataStore previously handled the synchronization and application of these rules automatically, the responsibility now shifts to you as the developer to integrate these auth rules into your chosen local data storage solution. + +Here are some key considerations: + +- **AppSync as the source of truth:** AWS AppSync remains the authoritative service for defining and enforcing authorization rules for your models. Ensure that your local data handling and synchronization strategies are aligned with the rules set in AppSync to maintain security and data consistency. +- **Managing local storage:** You are responsible for implementing the logic to save data to your selected local storage solution. This includes ensuring that your offline models are compatible with the authorization rules defined in AppSync. +- **Clearing local storage on sign-out:** As a best practice, clear your local storage whenever a user signs out of your application. This helps to prevent unauthorized access to data and ensures that each user's session begins with a fresh, secure state aligned with the current auth rules. + +## Handle existing customer data + +As you transition from Amplify DataStore to a new local storage solution, it is important to carefully manage the data currently stored on users' devices. The approach varies depending on whether your application is connected to AWS AppSync for remote synchronization or operates entirely in a local-only mode. + +### Connected apps using AWS AppSync + +If your application connects to AWS AppSync for data synchronization, AWS AppSync should be treated as the single source of truth. During migration: + +1. **Sync unsynced data.** Before transitioning to the new local store, ensure that any unsynced data on the device is successfully pushed to AWS AppSync. +2. **Re-sync from AppSync.** Once the unsynced data is uploaded, clear the existing local storage and initialize the new local store. Use AWS AppSync to re-sync all necessary data down to the device. +3. **Validate data.** After re-syncing, perform validation checks to confirm that the data in your new local store matches the data in AWS AppSync. + +### Local-only users + +If your application operates without a remote sync to AWS AppSync, handle the migration of local data manually. + +1. **Back up local data.** Before starting the migration, create a backup of the existing local data. +2. **Query and migrate local data.** Query all existing data from the current local store. Depending on the structure of your data, you may need to transform or reformat the data to fit the schema of your new local storage solution. +3. **Map data.** Carefully map the data from your existing store to your new solution, ensuring that all fields and relationships are preserved. diff --git a/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/disable-conflict-resolution/index.mdx b/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/disable-conflict-resolution/index.mdx index d2d733e78a2..334da00e835 100644 --- a/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/disable-conflict-resolution/index.mdx +++ b/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/disable-conflict-resolution/index.mdx @@ -4,11 +4,14 @@ export const meta = { title: 'Disable conflict resolution', description: 'Disable AppSync conflict resolution and prepare your backend for migration from DataStore to a standard GraphQL client with zero downtime.', platforms: [ + 'android', 'angular', + 'flutter', 'javascript', 'nextjs', 'react', 'react-native', + 'swift', 'vue' ] }; @@ -28,7 +31,7 @@ export function getStaticProps(context) { -**Complete the frontend migration first.** This page covers the **final backend step** of the migration. Before following these instructions, migrate all frontend clients from DataStore to Apollo Client (or the Amplify API category) using the preceding pages in this guide. Disabling conflict resolution while any client still uses DataStore will break that client immediately. +**Complete the frontend migration first.** This page covers the **final backend step** of the migration. Before following these instructions, migrate all frontend clients from DataStore using the preceding pages in this guide. Disabling conflict resolution while any client still uses DataStore will break that client immediately. diff --git a/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/helpful-resources/index.mdx b/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/helpful-resources/index.mdx new file mode 100644 index 00000000000..57afd517603 --- /dev/null +++ b/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/helpful-resources/index.mdx @@ -0,0 +1,131 @@ +import { getCustomStaticPath } from '@/utils/getCustomStaticPath'; + +export const meta = { + title: 'Helpful resources', + description: 'Additional documentation, third-party packages, and references for the DataStore migration.', + platforms: [ + 'android', + 'flutter', + 'swift' + ] +}; + +export const getStaticPaths = async () => { + return getCustomStaticPath(meta.platforms); +}; + +export function getStaticProps(context) { + return { + props: { + platform: context.params.platform, + meta + } + }; +} + + + +## Amplify API + +- [Amplify API Category — GraphQL](https://docs.amplify.aws/gen1/flutter/build-a-backend/graphqlapi/) +- [Amplify API — Mutate Data](https://docs.amplify.aws/gen1/flutter/build-a-backend/graphqlapi/mutate-data/) +- [Amplify API — Query Data](https://docs.amplify.aws/gen1/flutter/build-a-backend/graphqlapi/query-data/) +- [Amplify API — Subscribe to Data](https://docs.amplify.aws/gen1/flutter/build-a-backend/graphqlapi/subscribe-data/) + +## AWS AppSync + +- [Conflict detection and resolution](https://docs.aws.amazon.com/appsync/latest/devguide/conflict-detection-and-sync.html) + +## Amplify DataStore (Legacy) + +- [How DataStore works](https://docs.amplify.aws/gen1/flutter/build-a-backend/more-features/datastore/how-it-works/) +- [Schema updates](https://docs.amplify.aws/gen1/flutter/build-a-backend/more-features/datastore/schema-updates/) + +## General + +- [Android Offline First Guide](https://developer.android.com/topic/architecture/data-layer/offline-first) +- [Exponential Backoff and Jitter](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/) + +## Local storage packages + +While migrating off of DataStore, we found a few third-party packages to share. We do not maintain any of these packages and our experiences are limited to the versions we tested on. + +| Feature | [isar-community](https://pub.dev/packages/isar_community) | [Drift](https://pub.dev/packages/drift) | [sqlite3](https://pub.dev/packages/sqlite3) | +|---|---|---|---| +| Model Schemas | ✅ | ✅ | ✅ | +| Nested Models | ✅ | ✅ | ✅ | +| Custom Primary Key | ✅ | ✅ | ✅ | +| CRUD | ✅ | ✅ | ✅ | +| Query Predicates | ✅ | [Have to use where()](https://drift.simonbinder.eu/docs/dart-api/select/#where) | ✅ | +| Observe/ObserveQuery | ✅ | ✅ | [Via `updates` stream](https://pub.dev/documentation/sqlite3/latest/common/CommonDatabase/updates.html) | + + + +Isar does not support Gradle 8, which is a requirement for Amplify Flutter Android, but there is a [fork](https://github.com/isar/isar/issues/1470#issuecomment-1978766200) that resolves this. Drift is used with the Amplify Flutter packages, which can result in restrictions for what Drift versions you can use. + + + + + + + +## AWS AppSync + +- [AWS AppSync Apollo Extensions documentation (Swift)](https://docs.amplify.aws/swift/build-a-backend/data/aws-appsync-apollo-extensions/) +- [AWS AppSync Apollo Extensions GitHub repository](https://github.com/aws-amplify/aws-appsync-apollo-extensions-swift) +- [AWS AppSync Delta Sync guide](https://docs.aws.amazon.com/appsync/latest/devguide/tutorial-delta-sync.html) +- [Conflict detection and resolution](https://docs.aws.amazon.com/appsync/latest/devguide/conflict-detection-and-sync.html) + +## Apollo iOS + +- [Apollo iOS documentation](https://www.apollographql.com/docs/ios) +- [Apollo iOS Getting Started](https://www.apollographql.com/docs/ios/get-started) +- [Apollo iOS GitHub repository](https://github.com/apollographql/apollo-ios) +- [Apollo iOS Normalized Cache](https://www.apollographql.com/docs/ios/caching/introduction) +- [Apollo iOS Watching Queries](https://www.apollographql.com/docs/ios/fetching/queries#watching-queries) + +## Amplify DataStore (Legacy) + +- [How DataStore works](https://docs.amplify.aws/gen1/swift/build-a-backend/more-features/datastore/how-it-works/) +- [Schema updates](https://docs.amplify.aws/gen1/swift/build-a-backend/more-features/datastore/schema-updates/) +- [Client code generation (Amplify CLI)](https://docs.amplify.aws/gen1/swift/tools/cli-legacy/client-codegen/) + +## Local storage frameworks + +- [SwiftData documentation](https://developer.apple.com/documentation/swiftdata/) +- [CoreData documentation](https://developer.apple.com/documentation/coredata) + + + + + +## Android + +- [Android Offline First Guide](https://developer.android.com/topic/architecture/data-layer/offline-first) + +## AWS AppSync + +- [AWS AppSync Apollo Extensions documentation](https://docs.amplify.aws/android/build-a-backend/data/aws-appsync-apollo-extensions/) +- [AWS AppSync Delta Sync guide](https://docs.aws.amazon.com/appsync/latest/devguide/tutorial-delta-sync.html) +- [Conflict detection and resolution](https://docs.aws.amazon.com/appsync/latest/devguide/conflict-detection-and-sync.html) + +## Apollo Kotlin + +- [Apollo Kotlin documentation](https://www.apollographql.com/docs/kotlin) +- [Apollo Kotlin Getting Started](https://www.apollographql.com/docs/kotlin#getting-started) +- [Apollo Kotlin Gradle Plugin configuration](https://www.apollographql.com/docs/kotlin/advanced/plugin-configuration) +- [Apollo Kotlin Normalized Cache](https://www.apollographql.com/docs/kotlin/caching/normalized-cache) +- [Apollo Kotlin Query Watchers](https://www.apollographql.com/docs/kotlin/caching/query-watchers) + +## Amplify DataStore (Legacy) + +- [How DataStore works](https://docs.amplify.aws/gen1/android/build-a-backend/more-features/datastore/how-it-works/) +- [Schema updates](https://docs.amplify.aws/gen1/android/build-a-backend/more-features/datastore/schema-updates/) +- [Client code generation (Amplify CLI)](https://docs.amplify.aws/gen1/android/tools/cli-legacy/client-codegen/) + +## Local storage libraries + +- [Room (Android)](https://developer.android.com/training/data-storage/room) +- [SQLDelight](https://sqldelight.github.io/sqldelight) + + diff --git a/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/index.mdx b/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/index.mdx index 4fb9d689e78..e0865b6afef 100644 --- a/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/index.mdx +++ b/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/index.mdx @@ -3,14 +3,17 @@ import { getChildPageNodes } from '@/utils/getChildPageNodes'; export const meta = { title: 'Migrate from DataStore', - description: 'Learn how to migrate from Amplify DataStore to Apollo Client for queries, mutations, and caching with Amplify subscriptions for real-time updates.', + description: 'Learn how to migrate from Amplify DataStore to Apollo Client or the Amplify API category.', route: '/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore', platforms: [ + 'android', 'angular', + 'flutter', 'javascript', 'nextjs', 'react', 'react-native', + 'swift', 'vue' ] }; @@ -32,16 +35,40 @@ export function getStaticProps(context) { ## Understanding DataStore -AWS Amplify DataStore provided a local-first data layer that automatically synchronized data between your app and the cloud. When you used DataStore, you got several powerful capabilities without writing any synchronization logic yourself: a local database (IndexedDB in the browser) that persisted data across sessions, automatic bidirectional sync with your AppSync backend, built-in conflict resolution using version tracking, full offline support with mutation queuing and replay, and real-time updates through `observe()` and `observeQuery()`. +AWS Amplify DataStore provided a local-first data layer that automatically synchronized data between your app and the cloud. When you used DataStore, you got several powerful capabilities without writing any synchronization logic yourself: a local database that persisted data across sessions, automatic bidirectional sync with your AppSync backend, built-in conflict resolution using version tracking, full offline support with mutation queuing and replay, and real-time updates through `observe()` and `observeQuery()`. DataStore abstracted away the complexity of GraphQL operations, network state management, and data consistency. You worked with simple `save`, `query`, and `delete` methods on local models, and DataStore handled everything else behind the scenes. + + This guide shows you how to use Apollo Client for queries, mutations, and caching, combined with the Amplify library's built-in subscription support for real-time updates. Depending on how much of DataStore's feature set your app actually uses, you may find the migration simpler than expected. + + + + +This guide shows you how to migrate from DataStore to the Amplify API category for queries, mutations, and subscriptions. Depending on how much of DataStore's feature set your app actually uses, you may find the migration simpler than expected. + + + + + +This guide shows you how to migrate from DataStore to [Apollo Kotlin](https://www.apollographql.com/docs/kotlin) and the [AWS AppSync Apollo Extensions library](https://docs.amplify.aws/android/build-a-backend/data/aws-appsync-apollo-extensions/) for queries, mutations, and subscriptions. Depending on how much of DataStore's feature set your app actually uses, you may find the migration simpler than expected. + + + + + +This guide shows you how to migrate from DataStore to [Apollo iOS](https://www.apollographql.com/docs/ios) and the [AWS AppSync Apollo Extensions library](https://docs.amplify.aws/swift/build-a-backend/data/aws-appsync-apollo-extensions/) for queries, mutations, and subscriptions. Depending on how much of DataStore's feature set your app actually uses, you may find the migration simpler than expected. + + + Once frontend clients have been migrated off DataStore, [disable conflict resolution](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/disable-conflict-resolution/) on the backend to remove the sync infrastructure, simplify mutations (no more `_version` tracking), and switch from soft deletes to hard deletes. ## What this guide covers + + This guide walks you through the migration path from DataStore to Apollo Client. You will set up Apollo Client with your AppSync endpoint, migrate all CRUD operations and relationships, and optionally add persistent caching and optimistic updates. ## Quick comparison: before and after @@ -178,4 +205,194 @@ If you followed the optional [Add local caching](/gen1/[platform]/build-a-backen + + + + +This guide walks you through the migration path from DataStore to the Amplify API category. You will replace DataStore operations with API equivalents, and optionally add local caching and offline-first support using third-party Flutter packages. + +## Quick comparison: before and after + +Here is a quick look at how common DataStore operations translate to the Amplify API category: + +| DataStore Operation | Amplify API Equivalent | +|---------------------|------------------------| +| `Amplify.DataStore.save()` (create) | `Amplify.API.mutate(request: ModelMutations.create(...))` | +| `Amplify.DataStore.save()` (update) | `Amplify.API.mutate(request: ModelMutations.update(...))` | +| `Amplify.DataStore.delete()` | `Amplify.API.mutate(request: ModelMutations.delete(...))` | +| `Amplify.DataStore.query()` (single) | `Amplify.API.query(request: ModelQueries.get(...))` | +| `Amplify.DataStore.query()` (list) | `Amplify.API.query(request: ModelQueries.list(...))` | +| `Amplify.DataStore.observe()` | `Amplify.API.subscribe(ModelSubscriptions.onCreate/onUpdate/onDelete(...))` | +| `Amplify.DataStore.observeQuery()` | Initial `ModelQueries.list()` + three subscriptions (no direct equivalent) | +| `Amplify.DataStore.clear()` | No longer needed (no local DataStore to clear) | +| `Amplify.DataStore.start()` / `stop()` | No longer needed | + +## Use cases + +Not all customers need the full extent of DataStore's capabilities. Depending on your specific use case, migration can be straightforward: + +1. **Ease of API:** If your main reason for using DataStore was to simplify managing GraphQL queries or APIs, and your application does not need offline or local caching, migrate to the Amplify API category. This allows you to continue working with a simplified data management interface while consuming your existing Amplify resources. +2. **Local caching:** If you used DataStore primarily as a local caching layer, storing data temporarily on the device but not relying heavily on its offline-first capabilities, consider migrating to a simpler local caching solution using third-party Flutter packages. +3. **Offline-first:** If your business requirements rely on fully supporting offline-first functionality, the [offline-first](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/offline-first/) guide provides a high-level approach for migrating to a new offline-first solution. + +## How to use this guide + +Follow the pages in order. Each step builds on the previous one. + +1. **[Remove DataStore](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/remove-datastore/).** Remove the DataStore dependency from your project and configure the API plugin to use your models. + +2. **[Migrate to Amplify API](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/migrate-to-api/).** Replace all DataStore operations with API equivalents. Migrate `save`, `query`, `delete`, `observe`, and `observeQuery`. + +3. **[Optional: Local caching](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/local-caching/).** Set up a local cache for offline data access using third-party Flutter packages. + +4. **[Offline-first](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/offline-first/).** Build full offline support with remote sync, live syncing, network detection, and offline mutations. + +5. **[Authorization and data handling](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/authorization-and-data/).** Manage auth rules and handle existing customer data during migration. + +6. **[Disable conflict resolution](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/disable-conflict-resolution/).** Once ALL frontend clients are migrated off DataStore, remove the sync infrastructure, simplify mutations, and switch from soft deletes to hard deletes. + +7. **[Helpful resources](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/helpful-resources/).** Additional documentation, third-party packages, and references. + +## Migration checklist + +Use this checklist to plan and track your migration: + +1. **Update `pubspec.yaml`** — Remove the `amplify_datastore` dependency (see [Remove DataStore](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/remove-datastore/)) +2. **Update Amplify configuration** — Remove the DataStore plugin and pass `ModelProvider` to the API plugin +3. **Migrate CRUD operations** — Replace `DataStore.save()`, `DataStore.query()`, and `DataStore.delete()` with API equivalents (see [Migrate to Amplify API](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/migrate-to-api/)) +4. **Migrate real-time listeners** — Replace `DataStore.observe()` and `DataStore.observeQuery()` with API subscriptions +5. **Handle API response differences** — Adapt to `GraphQLResponse` types and token-based pagination +6. **Test** — Verify all CRUD operations, real-time subscriptions, and error handling +7. **Disable conflict resolution** — Once all clients are migrated (see [Disable conflict resolution](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/disable-conflict-resolution/)) + + + + + +This guide walks you through the migration path from DataStore to Apollo Kotlin. You will set up Apollo Kotlin with your AppSync endpoint, migrate all CRUD operations, and optionally add local caching and offline-first support. + +## Quick comparison: before and after + +Here is a quick look at how common DataStore operations translate to Apollo Kotlin: + +| DataStore Operation | Apollo Kotlin Equivalent | +|---------------------|--------------------------| +| `Amplify.DataStore.save()` | Apollo mutations (`CreatePostMutation`, `UpdatePostMutation`) | +| `Amplify.DataStore.delete()` | Apollo mutations (`DeletePostMutation`) | +| `Amplify.DataStore.query()` | Apollo queries (`GetPostQuery`, `GetPostsQuery`) | +| `Amplify.DataStore.observe()` | Apollo subscriptions (`OnCreateSubscription`, etc.) via `toFlow()` | +| `Amplify.DataStore.observeQuery()` | Apollo normalized cache + `watch()` | +| `Amplify.DataStore.clear()` | No longer needed (no local DataStore to clear) | +| `Amplify.DataStore.start()` / `stop()` | No longer needed | + +## Use cases + +Not all customers need the full extent of DataStore's capabilities. Depending on your specific use case, migration can be straightforward: + +1. **Local caching:** If you used DataStore primarily as a local caching layer, storing data temporarily on the device but not relying heavily on its offline-first capabilities, Apollo Kotlin can provide lightweight data caching without the complexities of managing full offline sync, making it a good fit if your application is mostly connected and only occasionally requires offline access. +2. **Offline-first:** If your business requirements rely on fully supporting offline-first functionality, the [offline-first](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/offline-first/) guide provides a high-level approach for migrating to a new offline-first solution. + +## How to use this guide + +Follow the pages in order. Each step builds on the previous one. + +1. **[Remove DataStore](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/remove-datastore/).** Remove the DataStore dependency and plugin from your project. + +2. **[Schema and GraphQL operations](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/schema-and-operations/).** Retrieve your AppSync schema and define your GraphQL operations for Apollo code generation. + +3. **[Set up Apollo Kotlin](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/set-up-apollo-kotlin/).** Add dependencies, configure the Gradle plugin, and set up the Apollo client with your AppSync endpoint and authentication. + +4. **[Migrate DataStore to Apollo](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/migrate-datastore-to-apollo/).** Replace all DataStore operations with Apollo equivalents. Migrate `save`, `query`, `delete`, `observe`, and `observeQuery`. + +5. **[Optional: Local caching](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/local-caching/).** Set up a local cache using Apollo's normalized cache or a custom caching layer with Room. + +6. **[Offline-first](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/offline-first/).** Build full offline support with remote sync, live syncing, network detection, and offline mutations. + +7. **[Disable conflict resolution](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/disable-conflict-resolution/).** Once ALL frontend clients are migrated off DataStore, remove the sync infrastructure, simplify mutations, and switch from soft deletes to hard deletes. + +8. **[Helpful resources](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/helpful-resources/).** Additional documentation, third-party packages, and references. + +## Migration checklist + +Use this checklist to plan and track your migration: + +1. **Remove the DataStore dependency** — Remove `com.amplifyframework:aws-datastore` from your `build.gradle.kts` (see [Remove DataStore](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/remove-datastore/)) +2. **Remove the DataStore plugin** — Remove `AWSDataStorePlugin()` from your `Amplify.addPlugin()` calls +3. **Delete generated model files** — Remove DataStore-generated model classes +4. **Set up schema and operations** — Retrieve your AppSync schema and define GraphQL operations (see [Schema and GraphQL operations](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/schema-and-operations/)) +5. **Set up Apollo Kotlin** — Add dependencies and configure the Apollo client (see [Set up Apollo Kotlin](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/set-up-apollo-kotlin/)) +6. **Migrate CRUD operations** — Replace `DataStore.save()`, `DataStore.query()`, and `DataStore.delete()` with Apollo equivalents (see [Migrate DataStore to Apollo](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/migrate-datastore-to-apollo/)) +7. **Migrate real-time listeners** — Replace `DataStore.observe()` and `DataStore.observeQuery()` with Apollo subscriptions and cache watchers +8. **Test** — Verify all CRUD operations, real-time subscriptions, and error handling +9. **Disable conflict resolution** — Once all clients are migrated (see [Disable conflict resolution](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/disable-conflict-resolution/)) + + + + + +This guide walks you through the migration path from DataStore to Apollo iOS. You will set up Apollo iOS with your AppSync endpoint, migrate all CRUD operations, and optionally add local caching and offline-first support. + +## Quick comparison: before and after + +Here is a quick look at how common DataStore operations translate to Apollo iOS: + +| DataStore Operation | Apollo iOS Equivalent | +|---------------------|--------------------------| +| `Amplify.DataStore.save()` | Apollo mutations (`CreatePostMutation`, `UpdatePostMutation`) | +| `Amplify.DataStore.delete()` | Apollo mutations (`DeletePostMutation`) | +| `Amplify.DataStore.query()` | Apollo queries (`GetPostQuery`, `GetPostsQuery`) | +| `Amplify.DataStore.observe()` | Apollo subscriptions (`OnCreateSubscriptionSubscription`, etc.) | +| `Amplify.DataStore.observeQuery()` | Apollo normalized cache + `watch()` | +| `Amplify.DataStore.clear()` | `apolloClient.store.clearCache()` | +| `Amplify.DataStore.start()` / `stop()` | No longer needed | + +## Use cases + +Not all customers need the full extent of DataStore's capabilities. Depending on your specific use case, migration can be straightforward: + +1. **Local caching:** If you used DataStore primarily as a local caching layer, storing data temporarily on the device but not relying heavily on its offline-first capabilities, Apollo iOS can provide lightweight data caching without the complexities of managing full offline sync, making it a good fit if your application is mostly connected and only occasionally requires offline access. +2. **Offline-first:** If your business requirements rely on fully supporting offline-first functionality, the [offline-first](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/offline-first/) guide provides a high-level approach for migrating to a new offline-first solution. + + + +**Version compatibility:** The [AWS AppSync Apollo Extensions library](https://github.com/aws-amplify/aws-appsync-apollo-extensions-swift) currently requires **Apollo iOS 1.x** (not 2.x). All versions of `aws-appsync-apollo-extensions-swift` (1.0.0–1.0.5) depend on Apollo iOS `1.0.0..<2.0.0`. Make sure you use Apollo iOS 1.x throughout this guide. If you install Apollo iOS 2.x alongside the extensions library, Swift Package Manager will fail to resolve dependencies. + + + +## How to use this guide + +Follow the pages in order. Each step builds on the previous one. + +1. **[Remove DataStore](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/remove-datastore/).** Remove the DataStore frameworks and plugin from your project. + +2. **[Schema and GraphQL operations](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/schema-and-operations/).** Retrieve your AppSync schema and define your GraphQL operations for Apollo code generation. + +3. **[Set up Apollo iOS](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/set-up-apollo-ios/).** Add dependencies, install the CLI, run code generation, and configure the Apollo client with your AppSync endpoint and authentication. + +4. **[Migrate DataStore to Apollo](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/migrate-datastore-to-apollo/).** Replace all DataStore operations with Apollo equivalents. Migrate `save`, `query`, `delete`, `observe`, and `observeQuery`. + +5. **[Optional: Local caching](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/local-caching/).** Set up a local cache using Apollo's normalized cache or a custom caching layer with SwiftData. + +6. **[Offline-first](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/offline-first/).** Build full offline support with remote sync, live syncing, network detection, and offline mutations. + +7. **[Disable conflict resolution](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/disable-conflict-resolution/).** Once ALL frontend clients are migrated off DataStore, remove the sync infrastructure, simplify mutations, and switch from soft deletes to hard deletes. + +8. **[Helpful resources](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/helpful-resources/).** Additional documentation and references. + +## Migration checklist + +Use this checklist to plan and track your migration: + +1. **Remove DataStore frameworks** — Remove `AWSDataStorePlugin` and `AWSAPIPlugin` from your target (see [Remove DataStore](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/remove-datastore/)) +2. **Remove the DataStore plugin** — Remove `AWSDataStorePlugin` from your `Amplify.add(plugin:)` calls +3. **Delete generated model files** — Remove DataStore-generated model classes (`Post.swift`, `Post+Schema.swift`, `AmplifyModels.swift`) +4. **Set up schema and operations** — Retrieve your AppSync schema and define GraphQL operations (see [Schema and GraphQL operations](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/schema-and-operations/)) +5. **Set up Apollo iOS** — Add dependencies, install CLI, run code generation, and configure the Apollo client (see [Set up Apollo iOS](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/set-up-apollo-ios/)) +6. **Migrate CRUD operations** — Replace `Amplify.DataStore.save()`, `Amplify.DataStore.query()`, and `Amplify.DataStore.delete()` with Apollo equivalents (see [Migrate DataStore to Apollo](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/migrate-datastore-to-apollo/)) +7. **Migrate real-time listeners** — Replace `Amplify.DataStore.observe()` and `Amplify.DataStore.observeQuery()` with Apollo subscriptions and cache watchers +8. **Test** — Verify all CRUD operations, real-time subscriptions, and error handling +9. **Disable conflict resolution** — Once all clients are migrated (see [Disable conflict resolution](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/disable-conflict-resolution/)) + + + diff --git a/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/local-caching/index.mdx b/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/local-caching/index.mdx new file mode 100644 index 00000000000..f21987b7a90 --- /dev/null +++ b/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/local-caching/index.mdx @@ -0,0 +1,310 @@ +import { getCustomStaticPath } from '@/utils/getCustomStaticPath'; + +export const meta = { + title: 'Optional: Local caching', + description: 'Set up local caching as an alternative to DataStore local storage for Flutter and Android.', + platforms: [ + 'android', + 'flutter', + 'swift' + ] +}; + +export const getStaticPaths = async () => { + return getCustomStaticPath(meta.platforms); +}; + +export function getStaticProps(context) { + return { + props: { + platform: context.params.platform, + meta + } + }; +} + +To guarantee quick and continuous access for customers, Amplify DataStore employs a local cached version of remote data. During your transition from Amplify DataStore, you should choose a local storage solution that aligns with your models to avoid unnecessary complexity. + + + +See the [helpful resources](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/helpful-resources/) page for third-party package suggestions. + +## Local models + +You will need to create a separate model for local storage that corresponds to your Amplify GraphQL generated model. To exclusively use your Amplify models, you must develop functions for mapping between your Amplify and local models, ensuring the local model remains concealed from the rest of your implementation. + +```dart +static LocalModel _toLocalModel(RemoteModel remote) { + return LocalModel( + id: remote.id, + someString: remote.someString, + createdAt: remote.createdAt?.getDateTimeInUtc(), + updatedAt: remote.updatedAt?.getDateTimeInUtc(), + ); +} + +static RemoteModel _toRemoteModel(LocalModel local) { + return RemoteModel( + id: local.id, + someString: local.someString, + ); +} +``` + +Certain database libraries lack or offer limited support for custom primary keys. You have the option to use the libraries' limited primary keys alongside your model's primary keys, but it is advisable to choose a database that accommodates the structure of your models. When changing your schema version, DataStore will discard your data to prevent any model incompatibilities. + +## CRUD + +The standard practice in local store operations involves the implementation of interfaces for creating, reading, updating, and deleting. However, it is crucial to consider the relationships between your models. In SQL, when handling 1:1 or 1:many relationships with models, it becomes necessary to create multiple tables and corresponding joins for CRUD operations. When dealing with NoSQL, a basic query for models with a many-to-many relationship may require the use of multiple sub-queries to link the relational data. + +### Queries + +Amplify DataStore uses query predicates, pagination, and sort by parameters to generate queries for Amplify API and its local store solution. Storing all data locally simplifies implementation by only requiring handling of local store queries. Otherwise, you have to generate matching queries for Amplify API and your local store. + +### Observability + +To support a responsive app, pick a local store that supports observing data changes. Alternatively, you can create your own observe Stream that you trigger after every create, update, or delete. + +```dart +Stream observe() { + return localRepository.observe(); +} +``` + + + + + +DataStore directly uses [SQLite](https://github.com/stephencelis/SQLite.swift) APIs, but the option you choose may depend on your caching requirements. + +## Apollo normalized cache + +Apollo iOS includes the option to use SQLite as a [normalized cache](https://www.apollographql.com/docs/ios/caching/introduction). The normalized cache is straightforward to set up and allows Apollo to replay previous queries from the cache to improve latency, reduce bandwidth consumption, and replay queries while offline. + +For setup instructions on the normalized cache, see the [Migrate DataStore to Apollo — ObserveQuery](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/migrate-datastore-to-apollo/#observequery) section which covers the `watch()` function that leverages the cache. + +## Custom caching with SwiftData + +For more advanced use cases where you want richer access to cached data, you can use your own caching layer instead of, or in addition to, Apollo's provided cache. Two popular frameworks for this approach are [SwiftData](https://developer.apple.com/documentation/swiftdata) and [CoreData](https://developer.apple.com/documentation/coredata). The following examples use SwiftData. + +### Local models + +You can use the `Model()` macro to annotate a separate entity for local storage that corresponds to your Apollo generated model: + +```swift +@Model +class PostEntity { + @Attribute(.unique) var id: String + var updatedAt: String + var createdAt: String + var status: PostEntityStatus? + var title: String + var rating: Int? + var content: String? + + init(id: String, + updatedAt: String, + createdAt: String, + status: PostEntityStatus? = nil, + title: String, + rating: Int? = nil, + content: String? = nil) { + self.id = id + self.updatedAt = updatedAt + self.createdAt = createdAt + self.status = status + self.title = title + self.rating = rating + self.content = content + } +} + +enum PostEntityStatus: Codable { + case active + case inactive +} +``` + +You can then configure the model storage using `ModelContainer`: + +```swift +let container = try ModelContainer(for: PostEntity.self) +``` + +### Model mapping + +Extension initializers can help map the remote model to your local model: + +```swift +extension PostEntity { + convenience init(postDetails: PostDetails) { + self.init(id: postDetails.id, + updatedAt: postDetails.updatedAt ?? "", + createdAt: postDetails.createdAt ?? "", + status: (postDetails.status == .case(.active)) ? .active : .inactive, + title: postDetails.title, + rating: postDetails.rating, + content: postDetails.content) + } +} +``` + +### Populate the cache + +Once your SwiftData models have been set up, you can populate the data by writing the results of Apollo queries into the cache: + +```swift +func populate(apolloClient: ApolloClient, context: ModelContext) { + var nextToken: String? = nil + repeat { + let query = GetPostsQuery( + nextToken: nextToken.flatMap { .some($0) } ?? .none) + apolloClient.fetch(query: query) { result in + guard let data = try? result.get().data else { return } + if let items = data.listPosts?.items { + for item in items { + guard let postDetails = item?.fragments.postDetails + else { continue } + let model = PostEntity(postDetails: postDetails) + context.insert(model) + try? context.save() + } + } + nextToken = data.listPosts?.nextToken + } + } while (nextToken != nil) +} +``` + +### Observability + +You can observe the [`.NSPersistentStoreRemoteChange`](https://developer.apple.com/documentation/foundation/nsnotification/name/3180044-nspersistentstoreremotechange) notification to listen to changes in the persistent store. The notification object contains the transaction's history token which you can use to fetch and process the history of transactions. + + + + + +DataStore directly uses Android's built-in SQLite database APIs, but the option you choose may depend on your caching requirements. See the [helpful resources](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/helpful-resources/) page for library suggestions. + +## Apollo normalized cache + +Apollo Kotlin includes the option to use SQLite as a [normalized cache](https://www.apollographql.com/docs/kotlin/caching/normalized-cache#sqlite-cache). The normalized cache is straightforward to set up and allows Apollo to replay previous queries from the cache to improve latency, reduce bandwidth consumption, and replay queries while offline. + +For setup instructions on the normalized cache, see the [Migrate DataStore to Apollo — ObserveQuery](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/migrate-datastore-to-apollo/#observequery) section which covers adding the dependency and configuring the cache. + +## Custom caching with Room + +For more advanced use cases where you want richer access to cached data, you can use your own caching layer instead of, or in addition to, Apollo's provided cache. Two popular libraries for this approach are [SQLDelight](https://sqldelight.github.io/sqldelight) and [Room](https://developer.android.com/training/data-storage/room). The following examples use Room. + +### Local models + +To cache models in your own cache, create a separate entity for local storage that corresponds to your Apollo generated model. In Room, the entity corresponding to the `PostDetails` fragment might look like this: + +```kotlin +@Entity(tableName = "post") +data class PostEntity( + @PrimaryKey val id: String, + val updatedAt: String, + val createdAt: String, + val title: String, + val content: String, + val status: PostStatus, + val rating: Int +) +``` + +You can then store, query, and mutate these entities with a DAO: + +```kotlin +@Dao +interface PostDao { + @Query("SELECT * FROM post") + suspend fun getAll(): List + + @Query("SELECT * FROM post WHERE id = :id") + suspend fun get(id: String): PostEntity + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(vararg posts: PostEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(posts: List) + + @Delete + suspend fun delete(post: PostEntity) +} +``` + +### Model mapping + +Extension functions let you map one model type to the other: + +```kotlin +fun PostDetails.toLocalModel() = PostEntity( + id = id, + updatedAt = updatedAt, + createdAt = createdAt, + title = title, + content = content, + status = status, + rating = rating +) + +fun PostEntity.toRemoteModel() = PostDetails( + id = id, + updatedAt = updatedAt, + createdAt = createdAt, + title = title, + content = content, + status = status, + rating = rating +) +``` + +When changing your schema version, DataStore will discard your data to prevent any model incompatibilities. In Room, you can instead use a [migration](https://developer.android.com/training/data-storage/room/migrating-db-versions) to keep your cached data intact. + +### Populate the cache + +Once your Room database has been created you can populate the data by writing the results of Apollo queries into the cache. The following code saves all `Post` objects in the local database: + +```kotlin +suspend fun syncAllPosts() { + // Sync all pages of posts until there is no nextToken + var nextToken: String? = null + do { + val query = GetPostsQuery(nextToken = Optional.presentIfNotNull(nextToken)) + val response = apolloClient.query(query).execute() + response.data?.listPosts?.items?.let { posts -> + val mapped = posts.mapNotNull { it?.postDetails?.toLocalModel() } + postDao.insertAll(mapped) + } + nextToken = response.data?.listPosts?.nextToken + } while (nextToken != null) +} +``` + +### Observability + +To support a responsive app, pick a local store that supports observing data changes. When using Room, you can react to changes in the database by changing your DAO to [return a Kotlin Flow](https://developer.android.com/training/data-storage/room/async-queries#observable). The updated observable DAO looks like this: + +```kotlin +@Dao +interface PostDao { + @Query("SELECT * FROM post") + fun getAll(): Flow> + + @Query("SELECT * FROM post WHERE id = :id") + fun get(id: String): Flow + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(vararg posts: PostEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(posts: List) + + @Delete + suspend fun delete(post: PostEntity) +} +``` + + diff --git a/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/migrate-datastore-to-apollo/index.mdx b/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/migrate-datastore-to-apollo/index.mdx new file mode 100644 index 00000000000..ebc9589f032 --- /dev/null +++ b/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/migrate-datastore-to-apollo/index.mdx @@ -0,0 +1,315 @@ +import { getCustomStaticPath } from '@/utils/getCustomStaticPath'; + +export const meta = { + title: 'Migrate DataStore to Apollo', + description: 'Replace every Amplify DataStore operation with its Apollo Kotlin or Apollo iOS equivalent.', + platforms: [ + 'android', + 'swift' + ] +}; + +export const getStaticPaths = async () => { + return getCustomStaticPath(meta.platforms); +}; + +export function getStaticProps(context) { + return { + props: { + platform: context.params.platform, + meta + } + }; +} + + + +This page covers how to replace every `Amplify.DataStore.*` call with the equivalent Apollo Kotlin operation. The examples below use the schema and GraphQL operations created in the [Schema and GraphQL operations](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/schema-and-operations/) page. + + + + + +This page covers how to replace every `Amplify.DataStore.*` call with the equivalent Apollo iOS operation. The examples below use the schema and GraphQL operations created in the [Schema and GraphQL operations](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/schema-and-operations/) page. + + + +## Create, update, and delete (save/delete) + + + +In Apollo Kotlin, you create, update, and delete data using the [mutations](https://www.apollographql.com/docs/kotlin/essentials/mutations) defined in your schema. + +```kotlin +// Create — include all required fields from your schema +val input = CreatePostInput( + title = title, + content = content, + rating = Optional.present(rating), + status = status +) +val response = apolloClient.mutation(CreatePostMutation(input)).execute() + +// Update — use Optional.present() for fields you want to change. +// Pass _version from the last query/mutation response for conflict resolution. +val input = UpdatePostInput( + id = post.id, + status = Optional.present(PostStatus.ACTIVE), + _version = Optional.present(post._version) +) +val response = apolloClient.mutation(UpdatePostMutation(input)).execute() + +// Delete — also requires _version for conflict resolution +val input = DeletePostInput( + id = post.id, + _version = Optional.present(post._version) +) +val response = apolloClient.mutation(DeletePostMutation(input)).execute() +``` + + + + + +In Apollo iOS, you create, update, and delete data using the [mutations](https://www.apollographql.com/docs/ios/fetching/mutations) defined in your schema. + +```swift +// Create — include all required fields from your schema +let createPostInput = CreatePostInput( + title: "post", + status: .some(.case(.active)), + rating: .some(5), + content: .some("content")) + +apolloClient.perform( + mutation: CreatePostMutation(input: createPostInput) +) { result in + guard let data = try? result.get().data else { return } + print("Post: \(String(describing: data.createPost?.fragments.postDetails))") +} + +// Update — use .some() for fields you want to change +let updatePostInput = UpdatePostInput( + id: "post1", + status: .some(.case(.inactive))) + +apolloClient.perform( + mutation: UpdatePostMutation(input: updatePostInput) +) { result in + guard let data = try? result.get().data else { return } + print("Post updated: \(String(describing: data.updatePost?.fragments.postDetails))") +} + +// Delete +let deletePostInput = DeletePostInput(id: "post1") +apolloClient.perform( + mutation: DeletePostMutation(input: deletePostInput) +) { result in + guard let data = try? result.get().data else { return } + print("Post deleted: \(String(describing: data.deletePost?.id))") +} +``` + + + +**`GraphQLNullable`:** Apollo iOS uses a `GraphQLNullable` type for optional input fields. This type distinguishes between "not provided" (`.none`), "explicitly null" (`.null`), and "has a value" (`.some(value)`). When setting optional fields with runtime values, you must use `.some(value)` explicitly — for example, `.some(.case(.active))` for enums or `.some(5)` for integers. + + + + + + + +**Include all required fields in creates.** If your schema defines `content: String!`, you must pass `content` in `CreatePostInput` — Apollo code generation makes it a required constructor parameter and the mutation will fail without it. + + + + + +**`_version` is required for updates and deletes.** If your AppSync API uses DataStore conflict resolution, you must pass the `_version` field when updating or deleting items. The `_version` is returned from every query and mutation response (via the `PostDetails` fragment). Always store the latest `_version` and pass it in subsequent mutations. If you omit `_version`, the mutation will fail with a `ConflictUnhandled` error. + + + +## Query + + + +In Apollo Kotlin, you query data using the [queries](https://www.apollographql.com/docs/kotlin/essentials/queries) defined in your schema. + +```kotlin +// Single item +val query = GetPostQuery(id = id) +val response = apolloClient.query(query).execute() + +// List items +val response = apolloClient.query(GetPostsQuery()).execute() +``` + + + + + +In Apollo iOS, you query data using the [queries](https://www.apollographql.com/docs/ios/fetching/queries) defined in your schema. + +```swift +// Single Item +let query = GetPostQuery(id: "post1") +apolloClient.fetch(query: query) { result in + guard let data = try? result.get().data else { return } + print("Query results: \(String(describing: data.getPost?.fragments.postDetails))") +} + +// List Items +let query = GetPostsQuery() +apolloClient.fetch(query: query) { result in + guard let data = try? result.get().data else { return } + print("List results: \(String(describing: data.listPosts?.items))") +} +``` + + + + + +**Soft-deleted items:** If your AppSync API uses DataStore conflict resolution, `listPosts` will return soft-deleted items with `_deleted: true`. DataStore filtered these out automatically, but with Apollo you **must filter them yourself** when displaying results: + +```kotlin +val posts = response.data?.listPosts?.items + ?.mapNotNull { it?.postDetails } + ?.filter { it._deleted != true } + ?: emptyList() +``` + +Failing to filter `_deleted` items will show deleted posts in your UI. + + + +### Filtering + +DataStore supported predicate queries (for example, `Post.RATING.gt(3)`) which were executed locally. With Apollo, you have two options: + +1. **Server-side filtering** using AppSync's `ModelPostFilterInput`. Update your `GetPosts` query to accept a filter: + +```graphql +query GetPosts($filter: ModelPostFilterInput, $nextToken: String) { + listPosts(filter: $filter, nextToken: $nextToken) { + items { ... PostDetails } + nextToken + } +} +``` + +Then pass the filter in Kotlin: + +```kotlin +val filter = ModelPostFilterInput( + rating = Optional.present(ModelIntInput(gt = Optional.present(3))) +) +val response = apolloClient.query( + GetPostsQuery(filter = Optional.present(filter)) +).execute() +``` + +2. **Client-side filtering** using Kotlin collection operations on the query results: + +```kotlin +val allPosts = response.data?.listPosts?.items + ?.mapNotNull { it?.postDetails } + ?: emptyList() +val filtered = allPosts.filter { (it.rating ?: 0) > 3 } +``` + +### Sorting + +DataStore's `QuerySortBy` does not have a direct Apollo equivalent for the default `listPosts` query. Apply sorting client-side: + +```kotlin +val sorted = posts.sortedByDescending { it.createdAt?.toString() ?: "" } +``` + +### Pagination + +DataStore supported offset-based pagination (`Page.startingAt(n).withLimit(5)`). AppSync uses cursor-based pagination with `nextToken`. You can either paginate through all results using `nextToken` or apply client-side offset pagination with `.drop()` and `.take()`: + +```kotlin +val page = 0 +val pageSize = 5 +val pagedPosts = allPosts.drop(page * pageSize).take(pageSize) +``` + +## Observe + +In Apollo Kotlin, you observe data using the [subscriptions](https://www.apollographql.com/docs/kotlin/essentials/subscriptions) defined in your schema. Apollo Kotlin exposes subscriptions as a Kotlin `Flow` via the built-in `toFlow()` method: + +```kotlin +apolloClient.subscription(OnCreateSubscription()).toFlow().collect { response -> + // Handle created post +} +``` + + + +Unlike DataStore's single `observe()` which emits create, update, and delete events through one stream, Apollo requires **separate subscriptions** for each event type (`onCreatePost`, `onUpdatePost`, `onDeletePost`). You need to launch each subscription flow independently. + + + +## ObserveQuery + +DataStore's `observeQuery` allows you to submit a query and then receive updates when the results change. To replicate this behavior with Apollo Kotlin, you use a [normalized cache](https://www.apollographql.com/docs/kotlin/caching/normalized-cache) and the [watch](https://www.apollographql.com/docs/kotlin/caching/query-watchers) function. + +To set this up, you need to: + +1. **Add the normalized cache dependency** to your `build.gradle.kts`: + +```kotlin +implementation("com.apollographql.apollo:apollo-normalized-cache:4.1.0") +// For SQLite-backed persistent cache: +implementation("com.apollographql.apollo:apollo-normalized-cache-sqlite:4.1.0") +``` + +2. **Configure the cache** when building your Apollo client: + +```kotlin +val cacheFactory = MemoryCacheFactory(maxSizeBytes = 10 * 1024 * 1024) +// Or for SQLite: SqlNormalizedCacheFactory(context, "apollo_cache.db") + +val apolloClient = ApolloClient.Builder() + .serverUrl(endpoint.serverUrl.toString()) + .addHttpInterceptor(AppSyncInterceptor(authorizer)) + .normalizedCache(cacheFactory) + .build() +``` + +3. **Watch the query** to receive updates: + +```kotlin +val query = GetPostQuery(id = id) +apolloClient.query(query).watch().collect { post -> + // This re-emits whenever the cached data for this query changes +} +``` + + + +The normalized cache tracks data by unique IDs, so mutations and subscription updates that write to the cache will automatically trigger `watch()` to re-emit with updated data. + + + + + +**No `isSynced` equivalent:** DataStore's `observeQuery()` provided an `isSynced` boolean indicating whether the local store was fully synced with the cloud. Apollo's `watch()` does not provide an equivalent sync status indicator. If your app relies on showing sync status, you need to track this manually (for example, by setting a flag after your initial query completes and subscriptions are established). + + + +## Quick reference table + +| DataStore Method | Apollo Kotlin Equivalent | Key Difference | +|---|---|---| +| `Amplify.DataStore.save()` (create) | `apolloClient.mutation(CreatePostMutation(input)).execute()` | No `_version` needed for creates | +| `Amplify.DataStore.save()` (update) | `apolloClient.mutation(UpdatePostMutation(input)).execute()` | Must pass `_version` from last query | +| `Amplify.DataStore.delete()` | `apolloClient.mutation(DeletePostMutation(input)).execute()` | Must pass `_version` from last query | +| `Amplify.DataStore.query()` (single) | `apolloClient.query(GetPostQuery(id)).execute()` | Returns null if not found | +| `Amplify.DataStore.query()` (list) | `apolloClient.query(GetPostsQuery()).execute()` | Must filter `_deleted` records | +| `Amplify.DataStore.observe()` | `apolloClient.subscription(...).toFlow().collect {}` | Separate subscription per event type | +| `Amplify.DataStore.observeQuery()` | `apolloClient.query(...).watch().collect {}` | Requires normalized cache setup | +| `Amplify.DataStore.clear()` | No longer needed | No local DataStore to clear | diff --git a/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/migrate-to-api/index.mdx b/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/migrate-to-api/index.mdx new file mode 100644 index 00000000000..d4232702cf2 --- /dev/null +++ b/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/migrate-to-api/index.mdx @@ -0,0 +1,462 @@ +import { getCustomStaticPath } from '@/utils/getCustomStaticPath'; + +export const meta = { + title: 'Migrate to Amplify API', + description: 'Replace DataStore save, query, delete, observe, and observeQuery with Amplify API equivalents for Flutter.', + platforms: [ + 'flutter' + ] +}; + +export const getStaticPaths = async () => { + return getCustomStaticPath(meta.platforms); +}; + +export function getStaticProps(context) { + return { + props: { + platform: context.params.platform, + meta + } + }; +} + +Amplify DataStore is built on top of the API category, using GraphQL queries, mutations, and subscriptions to interact with AWS AppSync. When migrating from DataStore, the API category provides the core building blocks to replicate DataStore's remote sync functionality. This page covers how to migrate every DataStore operation to its Amplify API equivalent. + +## Handle API responses + +A key difference between DataStore and the API category is the response type. DataStore methods return model objects or lists directly, while API methods return `GraphQLResponse` wrappers that may contain errors. + +```dart +// API query returns GraphQLResponse>, not List +final request = ModelQueries.list(Todo.classType); +final response = await Amplify.API.query(request: request).response; + +// Always check for errors +if (response.hasErrors) { + safePrint('Errors: ${response.errors}'); + return; +} + +// Access items from PaginatedResult — items may contain nulls +final todos = response.data?.items.nonNulls.toList() ?? []; +``` + +**Error handling patterns:** + +| Scenario | How to Detect | Action | +|---|---|---| +| Network error | `try/catch` around API call | Show offline message, retry later | +| GraphQL validation error | `response.hasErrors == true` and `response.data == null` | Fix request or show error | +| Partial success | `response.hasErrors == true` and `response.data != null` | Use data, log errors | +| Success | `response.hasErrors == false` and `response.data != null` | Use data | + +## Create and update (save) + +DataStore's `save()` handled both creating new items and updating existing ones. With the API category, you use separate `ModelMutations.create()` and `ModelMutations.update()` methods. + +**Before (DataStore):** + +```dart +// Create or update — DataStore decides based on whether the item exists +await Amplify.DataStore.save(todo); +``` + +**After (API) — create:** + +```dart +// Create — no _version needed, AppSync sets it to 1 automatically +final todo = Todo(name: 'my first todo', description: 'todo description'); +final request = ModelMutations.create(todo); +final response = await Amplify.API.mutate(request: request).response; + +if (response.hasErrors) { + safePrint('Create failed: ${response.errors}'); +} else { + safePrint('Created: ${response.data}'); +} +``` + +**After (API) — update:** + +```dart +// Update — the model instance carries _version internally. +// Always use a freshly queried instance to avoid ConditionalCheckFailedException. +final todoWithNewName = originalTodo.copyWith(name: 'new name'); +final updateRequest = ModelMutations.update(todoWithNewName); +final updateResponse = + await Amplify.API.mutate(request: updateRequest).response; + +if (updateResponse.hasErrors) { + safePrint('Update failed: ${updateResponse.errors}'); +} else { + safePrint('Updated: ${updateResponse.data}'); +} +``` + + + +**`_version` is required for updates.** The Amplify Flutter model objects carry `_version` internally, so `ModelMutations.update()` includes it automatically — but only if the model instance has the current version. Always query the record first to get the latest `_version`. If you see `ConditionalCheckFailedException`, you are passing a stale `_version`. + + + +## Delete + +DataStore's `delete()` method removes an item by passing the model instance. The API equivalent uses `ModelMutations.delete()`. When conflict resolution is enabled, delete is a **soft delete** — the record's `_deleted` field is set to `true` in DynamoDB, but the record is not physically removed. + +**Before (DataStore):** + +```dart +await Amplify.DataStore.delete(todo); +``` + +**After (API):** + +```dart +final request = ModelMutations.delete(todo); +final response = await Amplify.API.mutate(request: request).response; + +if (response.hasErrors) { + safePrint('Delete failed: ${response.errors}'); +} else { + safePrint('Deleted: ${response.data}'); +} +``` + +You can also delete by ID using `ModelMutations.deleteById()`: + +```dart +final request = ModelMutations.deleteById( + Todo.classType, + TodoModelIdentifier(id: todoId), +); +final response = await Amplify.API.mutate(request: request).response; +``` + + + +**`_version` is required for deletes.** Like updates, the model instance must carry the current `_version`. Query the record first if you are not sure you have the latest version. + + + +## Soft deletes and `_deleted` + +When conflict resolution is enabled, deletes are **soft deletes** — the record's `_deleted` field is set to `true` in DynamoDB, but the record is not physically removed. DataStore filtered these automatically; the API category does not. This means `ModelQueries.list()` returns soft-deleted records alongside active ones. + +The Amplify Flutter codegen models do not expose `_deleted` as a Dart property by default. To enable client-side filtering, add `_deleted` to your models in `schema.graphql` and re-run `amplify codegen models`: + +```graphql +type Todo @model { + id: ID! + name: String! + description: String + _deleted: Boolean +} +``` + +Then filter in Dart on every list query: + +```dart +final todos = response.data?.items.nonNulls + .where((t) => t.deleted != true) + .toList() ?? []; +``` + +Once you [disable conflict resolution](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/disable-conflict-resolution/) as the final migration step, soft deletes stop and this filtering is no longer needed. + +## Query + +DataStore's `query()` method supports query predicates, sorting, and page-based pagination. The API category supports query predicates through the `where` parameter but handles sorting and pagination differently. + +**Before (DataStore):** + +```dart +final todos = await Amplify.DataStore.query( + Todo.classType, + where: Todo.NAME.contains('important'), + sortBy: [Todo.CREATEDAT.descending()], + pagination: QueryPagination(page: 0, limit: 10), +); +// Returns List directly +``` + +**After (API) — single item:** + +```dart +final request = ModelQueries.get( + Todo.classType, + queriedTodo.modelIdentifier, +); +final response = await Amplify.API.query(request: request).response; +final todo = response.data; // Todo? — may be null +``` + +**After (API) — list with predicates:** + +```dart +final request = ModelQueries.list( + Todo.classType, + where: Todo.NAME.contains('important'), +); +final response = await Amplify.API.query(request: request).response; +final todos = response.data?.items.nonNulls + .where((t) => t.deleted != true) + .toList() ?? []; +``` + +### Sorting + +The API category's `ModelQueries.list()` does not support a `sortBy` parameter. You must implement sorting on the client side after receiving results: + +```dart +final request = ModelQueries.list(Todo.classType); +final response = await Amplify.API.query(request: request).response; +final todos = response.data?.items.nonNulls + .where((t) => t.deleted != true) + .toList() ?? []; + +// Client-side sort (e.g., by creation date, newest first) +todos.sort((a, b) { + final aDate = a.createdAt?.getDateTimeInUtc() ?? DateTime(0); + final bDate = b.createdAt?.getDateTimeInUtc() ?? DateTime(0); + return bDate.compareTo(aDate); +}); +``` + +### Pagination + +DataStore uses page-based pagination (`QueryPagination(page: N, limit: M)`), while the API category uses **token-based pagination** via `nextToken` on `PaginatedResult`. To paginate through all results: + +```dart +List allTodos = []; +GraphQLRequest> request = ModelQueries.list( + Todo.classType, + limit: 100, +); + +while (true) { + final response = await Amplify.API.query(request: request).response; + final page = response.data; + if (page == null) break; + + allTodos.addAll(page.items.nonNulls); + + if (page.hasNextResult) { + request = page.requestForNextResult!; + } else { + break; + } +} +``` + +If you need page-based pagination in your UI, you can implement it on the client side by fetching all items and then slicing: + +```dart +final pageSize = 10; +final pageIndex = 0; // zero-based + +// Fetch all (or use token-based pagination to fetch enough) +final allTodos = await _fetchAllTodos(); + +// Slice for display +final pageItems = allTodos.skip(pageIndex * pageSize).take(pageSize).toList(); +final totalPages = (allTodos.length / pageSize).ceil(); +``` + +## Observe + +DataStore's `observe()` returns a single stream with events that include an `EventType` (create, update, delete). The API category uses three separate subscription streams — one each for `onCreate`, `onUpdate`, and `onDelete`. + +**Before (DataStore):** + +```dart +final stream = Amplify.DataStore.observe(Todo.classType); +stream.listen((event) { + switch (event.eventType) { + case EventType.create: + safePrint('Created: ${event.item}'); + break; + case EventType.update: + safePrint('Updated: ${event.item}'); + break; + case EventType.delete: + safePrint('Deleted: ${event.item}'); + break; + } +}); +``` + +**After (API) — three separate subscriptions:** + +```dart +late StreamSubscription> _createSub; +late StreamSubscription> _updateSub; +late StreamSubscription> _deleteSub; + +void _initSubscriptions() { + // onCreate + final onCreateRequest = ModelSubscriptions.onCreate(Todo.classType); + _createSub = Amplify.API + .subscribe(onCreateRequest, + onEstablished: () => + safePrint('onCreate subscription established')) + .listen( + (event) { + safePrint('Created: ${event.data}'); + }, + onError: (Object e) => safePrint('onCreate error: $e'), + ); + + // onUpdate + final onUpdateRequest = ModelSubscriptions.onUpdate(Todo.classType); + _updateSub = Amplify.API + .subscribe(onUpdateRequest, + onEstablished: () => + safePrint('onUpdate subscription established')) + .listen( + (event) { + safePrint('Updated: ${event.data}'); + }, + onError: (Object e) => safePrint('onUpdate error: $e'), + ); + + // onDelete + final onDeleteRequest = ModelSubscriptions.onDelete(Todo.classType); + _deleteSub = Amplify.API + .subscribe(onDeleteRequest, + onEstablished: () => + safePrint('onDelete subscription established')) + .listen( + (event) { + safePrint('Deleted: ${event.data}'); + }, + onError: (Object e) => safePrint('onDelete error: $e'), + ); +} + +@override +void dispose() { + _createSub.cancel(); + _updateSub.cancel(); + _deleteSub.cancel(); + super.dispose(); +} +``` + + + +Unlike DataStore's single `observe()` stream, you now manage three separate subscriptions. Remember to cancel all three when your widget or controller is disposed. + + + +## ObserveQuery + +DataStore's `observeQuery()` combines an initial query with real-time updates, returning a `QuerySnapshot` that contains the full list of matching items and an `isSynced` flag. There is no direct equivalent in the API category — you must compose this behavior yourself using an initial list query plus subscriptions. + +**Before (DataStore):** + +```dart +final stream = Amplify.DataStore.observeQuery( + Todo.classType, + where: Todo.STATUS.eq(TodoStatus.ACTIVE), + sortBy: [Todo.CREATEDAT.descending()], +); + +stream.listen((QuerySnapshot snapshot) { + safePrint( + 'Items: ${snapshot.items.length}, isSynced: ${snapshot.isSynced}'); + setState(() { + _todos = snapshot.items; + }); +}); +``` + +**After (API) — initial query + subscriptions for reactive updates:** + +```dart +List _todos = []; +bool _isLoading = true; +late StreamSubscription _createSub; +late StreamSubscription _updateSub; +late StreamSubscription _deleteSub; + +Future _initObserveQuery() async { + // Step 1: Perform initial list query + await _refreshList(); + + // Step 2: Subscribe to changes and refresh on each event + final onCreate = ModelSubscriptions.onCreate(Todo.classType); + _createSub = + Amplify.API.subscribe(onCreate).listen((_) => _refreshList()); + + final onUpdate = ModelSubscriptions.onUpdate(Todo.classType); + _updateSub = + Amplify.API.subscribe(onUpdate).listen((_) => _refreshList()); + + final onDelete = ModelSubscriptions.onDelete(Todo.classType); + _deleteSub = + Amplify.API.subscribe(onDelete).listen((_) => _refreshList()); +} + +Future _refreshList() async { + final request = ModelQueries.list( + Todo.classType, + where: Todo.STATUS.eq(TodoStatus.ACTIVE), + ); + final response = await Amplify.API.query(request: request).response; + + if (response.hasErrors) { + safePrint('Query errors: ${response.errors}'); + return; + } + + final items = response.data?.items.nonNulls + .where((t) => t.deleted != true) + .toList() ?? []; + + // Client-side sort (API does not support sortBy) + items.sort((a, b) { + final aDate = a.createdAt?.getDateTimeInUtc() ?? DateTime(0); + final bDate = b.createdAt?.getDateTimeInUtc() ?? DateTime(0); + return bDate.compareTo(aDate); + }); + + setState(() { + _todos = items; + _isLoading = false; + }); +} + +@override +void dispose() { + _createSub.cancel(); + _updateSub.cancel(); + _deleteSub.cancel(); + super.dispose(); +} +``` + + + +DataStore's `QuerySnapshot.isSynced` flag is not available through the API category. If you relied on this flag to show sync status in your UI, you will need to track loading state manually (as shown with `_isLoading` above). + + + + + +**Performance tip:** For an optimized approach, instead of re-querying the entire list on every subscription event, you can update the local list in-place by adding, updating, or removing the item from the subscription event data. This avoids unnecessary network calls but requires more code to manage the list state. + + + +## Quick reference table + +| DataStore Method | Amplify API Equivalent | Key Difference | +|---|---|---| +| `Amplify.DataStore.save()` (create) | `Amplify.API.mutate(request: ModelMutations.create(...))` | Must check `response.hasErrors`; no `_version` needed for creates | +| `Amplify.DataStore.save()` (update) | `Amplify.API.mutate(request: ModelMutations.update(...))` | Must query first to get latest `_version`; model carries it internally | +| `Amplify.DataStore.delete()` | `Amplify.API.mutate(request: ModelMutations.delete(...))` | Must query first to get latest `_version`; delete is a soft delete when conflict resolution is enabled | +| `Amplify.DataStore.query()` (single) | `Amplify.API.query(request: ModelQueries.get(...))` | Returns `GraphQLResponse` wrapper | +| `Amplify.DataStore.query()` (list) | `Amplify.API.query(request: ModelQueries.list(...))` | Token-based pagination, no `sortBy`; returns soft-deleted records | +| `Amplify.DataStore.observe()` | `Amplify.API.subscribe(ModelSubscriptions.onCreate/onUpdate/onDelete(...))` | Three separate subscriptions | +| `Amplify.DataStore.observeQuery()` | Initial list query + three subscriptions | No direct equivalent; compose manually | +| `Amplify.DataStore.clear()` | No longer needed | No local DataStore to clear | diff --git a/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/offline-first/index.mdx b/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/offline-first/index.mdx new file mode 100644 index 00000000000..81bf6592e19 --- /dev/null +++ b/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/offline-first/index.mdx @@ -0,0 +1,393 @@ +import { getCustomStaticPath } from '@/utils/getCustomStaticPath'; + +export const meta = { + title: 'Offline-first', + description: 'Build full offline support with remote sync, live syncing, network detection, and offline mutations.', + platforms: [ + 'android', + 'flutter', + 'swift' + ] +}; + +export const getStaticPaths = async () => { + return getCustomStaticPath(meta.platforms); +}; + +export function getStaticProps(context) { + return { + props: { + platform: context.params.platform, + meta + } + }; +} + +For a better understanding of Amplify DataStore's opinionated approach, consult these resources: + + + +- [How DataStore works](https://docs.amplify.aws/gen1/flutter/build-a-backend/more-features/datastore/how-it-works/) +- [Conflict detection and resolution](https://docs.aws.amazon.com/appsync/latest/devguide/conflict-detection-and-sync.html) +- [Schema updates](https://docs.amplify.aws/gen1/flutter/build-a-backend/more-features/datastore/schema-updates/) + + + + + +- [How DataStore works](https://docs.amplify.aws/gen1/android/build-a-backend/more-features/datastore/how-it-works/) +- [Conflict detection and resolution](https://docs.aws.amazon.com/appsync/latest/devguide/conflict-detection-and-sync.html) +- [Schema updates](https://docs.amplify.aws/gen1/android/build-a-backend/more-features/datastore/schema-updates/) + + + + + +- [How DataStore works](https://docs.amplify.aws/gen1/swift/build-a-backend/more-features/datastore/how-it-works/) +- [Conflict detection and resolution](https://docs.aws.amazon.com/appsync/latest/devguide/conflict-detection-and-sync.html) +- [Schema updates](https://docs.amplify.aws/gen1/swift/build-a-backend/more-features/datastore/schema-updates/) + + + +To further explore and understand the principles of building offline-first applications, we recommend reviewing the [Android guide for building offline-first apps](https://developer.android.com/topic/architecture/data-layer/offline-first). + +We understand the importance of these capabilities to your applications, and while DataStore will no longer receive new features, we want to ensure that you have the necessary resources to transition smoothly. The following sections provide high-level recommendations on alternative solutions. + +## Remote sync + +By regularly synchronizing your local store, it can serve as the primary source for your application, allowing for read operations that can be done offline. Opting to manage offline writes will introduce additional complexity to your application, so it is crucial to consider whether you want to handle this for each individual model. + +## Live syncing + + + +You can achieve real-time detection of changes to a remote repository by leveraging `Amplify.API.subscribe` with `onCreate`, `onUpdate`, and `onDelete`, as long as you are connected to the internet. This method is not sufficient on its own since the subscriptions do not activate when the app is offline. + +```dart +Future initOnlineSync() async { + final onCreate = ModelSubscriptions.onCreate(RemoteModel.classType); + final createSubscription = Amplify.API.subscribe(onCreate); + createSubscription.listen((response) { + _syncItem(response, localRepository.create); + }); + + final onUpdate = ModelSubscriptions.onUpdate(RemoteModel.classType); + final updateSubscription = Amplify.API.subscribe(onUpdate); + updateSubscription.listen((response) { + _syncItem(response, localRepository.update); + }); + + final onDelete = ModelSubscriptions.onDelete(RemoteModel.classType); + final deleteSubscription = Amplify.API.subscribe(onDelete); + deleteSubscription.listen((response) { + _syncItem(response, localRepository.delete); + }); +} + +Future _syncItem( + GraphQLResponse response, + Function(LocalModel) syncFunction, +) async { + final remoteModel = response.data; + if (remoteModel == null) return; + + final localModel = toLocalModel(remoteModel); + // Calls localRepository's create, update, or delete method + syncFunction.call(localModel); +} +``` + + + + + +You can achieve real-time detection of changes (create, update, delete) to a remote repository by leveraging AWS AppSync subscriptions, as long as you are connected to the internet. The following function subscribes to remote creation, updates, and deletion and propagates those changes into your Room database. These subscriptions will need to be established each time your application reconnects to the internet. + +```kotlin +suspend fun startSubscriptions() = supervisorScope { + // Subscribe to creates + apolloClient.subscription(OnCreateSubscription()).toFlow().onEach { response -> + response.data?.onCreatePost?.postDetails?.let { post -> + postDao.insert(post.toLocalModel()) + } + }.launchIn(this) + + // Subscribe to updates + apolloClient.subscription(OnUpdateSubscription()).toFlow().onEach { response -> + response.data?.onUpdatePost?.postDetails?.let { post -> + postDao.insert(post.toLocalModel()) + } + }.launchIn(this) + + // Subscribe to deletes + apolloClient.subscription(OnDeleteSubscription()).toFlow().onEach { response -> + response.data?.onDeletePost?.postDetails?.let { post -> + postDao.delete(post.toLocalModel()) + } + }.launchIn(this) +} +``` + + + +## Local cache refresh + +Whenever your app reconnects to the internet, whether through launching the app or network updates, perform a complete refresh of your local store to ensure that you receive all the updates that you may have missed. + + + +```dart +Future _syncAll() async { + final response = await remoteRepository.readAll(); + final remoteItems = response.data?.items; + final localData = remoteItems?.nonNulls.map(toLocalModel); + + if (localData == null) return; + + // Clear local and save results + localRepository.rebuild(localData); +} +``` + + + + + +```kotlin +suspend fun syncAllPosts() { + // Sync all pages of posts until there is no nextToken + var nextToken: String? = null + do { + val query = GetPostsQuery(nextToken = Optional.presentIfNotNull(nextToken)) + val response = apolloClient.query(query).execute() + response.data?.listPosts?.items?.let { posts -> + val mapped = posts.mapNotNull { it?.postDetails?.toLocalModel() } + postDao.insertAll(mapped) + } + nextToken = response.data?.listPosts?.nextToken + } while (nextToken != null) +} +``` + +If you often sync large amounts of data, configuring AWS AppSync Delta Sync operations can reduce the amount of time it takes to refresh your local store. Read more about how to configure and implement Delta Sync functionality in the [AWS AppSync Delta Sync guide](https://docs.aws.amazon.com/appsync/latest/devguide/tutorial-delta-sync.html). + + + +## Detect network status + + + +You can reactively detect network status updates by waiting for an `Amplify.API.mutate` operation to throw an exception or for your subscription observers to send an error. + +```dart +void _initObservers() { + final onCreate = ModelSubscriptions.onCreate(Todo.classType); + Amplify.API + .subscribe(onCreate) + .listen(_syncCreate, onError: (_) => _offlineDetected()); + + final onUpdate = ModelSubscriptions.onUpdate(Todo.classType); + Amplify.API + .subscribe(onUpdate) + .listen(_syncUpdate, onError: (_) => _offlineDetected()); + + final onDelete = ModelSubscriptions.onDelete(Todo.classType); + Amplify.API + .subscribe(onDelete) + .listen(_syncDelete, onError: (_) => _offlineDetected()); +} + +Future> create(Todo model) async { + try { + final request = ModelMutations.create(model); + return Amplify.API.mutate(request: request).response; + } catch (e, st) { + _offlineDetected(); + rethrow; + } +} +``` + +Additionally, you can proactively detect network status updates via Amplify Hub. This allows you to identify scenarios where your local cache needs to be synced even though you are not actively making network requests. + +```dart +Amplify.Hub.listen(HubChannel.Api, _onNetworkStatusChanged); + +void _onNetworkStatusChanged(ApiHubEvent hubEvent) { + if (hubEvent is SubscriptionHubEvent) { + switch (hubEvent.status) { + case SubscriptionStatus.connected: + _onlineDetected(); + break; + case SubscriptionStatus.connecting: + case SubscriptionStatus.pendingDisconnected: + case SubscriptionStatus.disconnected: + case SubscriptionStatus.failed: + _offlineDetected(); + default: + break; + } + } +} +``` + +Once you have detected that the application is offline, periodically attempt to resync your local cache. This allows you to detect when your app is back online and cleans up any stale data. We recommend implementing a [backoff strategy](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/) or using a third-party package to efficiently distribute your resync attempts. + +```dart +Future _offlineDetected() async { + if (_isOffline) return; + _isOffline = true; + + final backoffStrategy = ExponentialJitterBackoff(); + while (_isOffline) { + await Future.delayed(backoffStrategy.nextDelay()); + + _isOffline = !await _resync(); + } +} +``` + + + + + +You can proactively detect network status updates via Android's [ConnectivityManager](https://developer.android.com/training/monitoring-device-state/connectivity-status-type). This allows you to identify scenarios where your local cache needs to be synced and subscriptions need to be reestablished. + +```kotlin +class NetworkMonitor { + + enum class Status { + Connected, + Disconnected + } + + fun monitor(context: Context) = callbackFlow { + val networkCallback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + super.onAvailable(network) + trySend(Status.Connected) + } + + override fun onLost(network: Network) { + super.onLost(network) + trySend(Status.Disconnected) + } + } + + val networkRequest = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) + .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR) + .build() + + val connectivityManager = + context.getSystemService(ConnectivityManager::class.java) + as ConnectivityManager + connectivityManager.requestNetwork(networkRequest, networkCallback) + + awaitClose { + connectivityManager.unregisterNetworkCallback(networkCallback) + } + } +} + +class SyncEngine(val networkMonitor: NetworkMonitor) { + suspend fun start(context: Context) { + networkMonitor.monitor(context).collect { networkStatus -> + when (networkStatus) { + NetworkMonitor.Status.Connected -> startSync() + NetworkMonitor.Status.Disconnected -> stopSync() + } + } + } +} +``` + + + +## Offline mutations + +By storing any changes made locally in a separate pending mutations table and synchronizing them at a later time, you can enable your users to work offline. Your pending mutations table should contain: + +1. Auto incrementing primary key (not your model's normal primary key) +2. Enum for type of mutation (create, update, or delete) +3. The state of the model at the time of the action + + + +As you are syncing your pending mutations table, handle your `Amplify.API.mutate` responses per the following table: + +| Exception Was Thrown | Response Has Data | Response hasErrors | Result | Action | +|---|---|---|---|---| +| TRUE | N/A | N/A | Exception | Pause Syncing | +| FALSE | TRUE | FALSE | Success | Continue Syncing | +| FALSE | TRUE | TRUE | Partial Success | Continue Syncing | +| FALSE | FALSE | TRUE | Validation Error | Continue Syncing | +| FALSE | FALSE | FALSE | Not a Valid Result | N/A | + +To incorporate pending mutations table entries into your app before syncing, you must aggregate them with the results of your local store queries. + +## Manually start and stop sync + +DataStore allows for syncing of data to be manually stopped and started. To implement this, you can check if syncing is enabled before running the sync logic and provide a way to toggle the flag. Remember to sync your local store when enabling sync. + +```dart +bool _syncEnabled = true; +void enableSync() { + final previous = _syncEnabled; + _syncEnabled = true; + + if (previous != _syncEnabled) _syncAll(); +} + +void disableSync() { + _syncEnabled = false; +} +``` + + + + + + + +Offline mutations can introduce significant complexity to your application. Synchronization, reconciliation, and conflict resolution can each be a non-trivial problem. You should carefully consider the pros and cons before deciding to support offline mutations. + + + +As an example, a pending mutation for the sample `Post` type might look like the following: + +```kotlin +enum class MutationType { + Create, + Update, + Delete +} + +@Entity(tableName = "post_mutation") +data class PostMutationEntity( + val postId: String, + val type: MutationType, + val title: String?, + val content: String?, + val status: PostStatus?, + val rating: Int?, + val timestamp: String, + @PrimaryKey(autoGenerate = true) val id: Int = 0 +) +``` + +### Apply pending mutations + +These pending mutations can be synced to AWS AppSync when the application has connectivity. To incorporate pending mutation table entries into your app before syncing, you must aggregate them with the results of your local store queries. You can fetch both the `PostEntity` and `PostMutationEntity` instances, and conflate the two into a single display model, optimistically applying the mutation before it is processed by AWS AppSync. + +```kotlin +fun PostMutationEntity.applyTo(post: PostEntity) = post.copy( + title = title ?: post.title, + content = content ?: post.content, + status = status ?: post.status, + rating = rating ?: post.rating +) +``` + + diff --git a/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/remove-datastore/index.mdx b/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/remove-datastore/index.mdx new file mode 100644 index 00000000000..557317fbec6 --- /dev/null +++ b/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/remove-datastore/index.mdx @@ -0,0 +1,231 @@ +import { getCustomStaticPath } from '@/utils/getCustomStaticPath'; + +export const meta = { + title: 'Remove DataStore', + description: 'Remove Amplify DataStore from your project and prepare for migration to the Amplify API category or Apollo Kotlin.', + platforms: [ + 'android', + 'flutter', + 'swift' + ] +}; + +export const getStaticPaths = async () => { + return getCustomStaticPath(meta.platforms); +}; + +export function getStaticProps(context) { + return { + props: { + platform: context.params.platform, + meta + } + }; +} + + + +Before migrating your DataStore calls, remove Amplify DataStore from your project and configure the API plugin to use your models. + +## Update dependencies + +Remove the `amplify_datastore` package from your `pubspec.yaml` and ensure the `amplify_api` package is present: + +```yaml +dependencies: + # Remove this line: + # amplify_datastore: ^2.x.x + + # Keep or add these: + amplify_flutter: ^2.x.x + amplify_api: ^2.x.x + amplify_auth_cognito: ^2.x.x # if using auth +``` + +After updating `pubspec.yaml`, run: + +```bash +flutter pub get +``` + +## Update Amplify configuration + +When using DataStore, the `ModelProvider` was passed to the `AmplifyDataStore` plugin. After removing DataStore, you **must** pass `ModelProvider.instance` to the `AmplifyAPI` plugin instead. Without this, `ModelMutations` and `ModelQueries` cannot serialize and deserialize your models, and API calls will fail at runtime. + +**Before (with DataStore):** + +```dart +import 'package:amplify_datastore/amplify_datastore.dart'; +import 'package:amplify_api/amplify_api.dart'; +import 'models/ModelProvider.dart'; + +final datastorePlugin = AmplifyDataStore(modelProvider: ModelProvider.instance); +final api = AmplifyAPI(); + +await Amplify.addPlugins([datastorePlugin, api]); +await Amplify.configure(amplifyConfig); +``` + +**After (API only):** + +```dart +import 'package:amplify_api/amplify_api.dart'; +import 'package:amplify_api/model_queries.dart'; +import 'package:amplify_api/model_mutations.dart'; +import 'package:amplify_api/model_subscriptions.dart'; +import 'models/ModelProvider.dart'; + +// Pass ModelProvider to the API plugin +final api = AmplifyAPI( + options: APIPluginOptions(modelProvider: ModelProvider.instance), +); + +await Amplify.addPlugins([api]); +await Amplify.configure(amplifyConfig); +``` + + + +If you forget to pass `ModelProvider.instance` to `AmplifyAPI`, model-based queries and mutations will not work. This is the most common migration mistake. + + + +## Remove all DataStore calls + +Search your codebase and remove or replace all DataStore calls: + +| DataStore Call | API Replacement | +|---|---| +| `Amplify.DataStore.save()` | `Amplify.API.mutate()` with `ModelMutations.create()` / `.update()` | +| `Amplify.DataStore.delete()` | `Amplify.API.mutate()` with `ModelMutations.delete()` | +| `Amplify.DataStore.query()` | `Amplify.API.query()` with `ModelQueries.get()` / `.list()` | +| `Amplify.DataStore.observe()` | `Amplify.API.subscribe()` with `ModelSubscriptions.onCreate/onUpdate/onDelete()` | +| `Amplify.DataStore.observeQuery()` | Initial list query + three subscriptions (see [Migrate to Amplify API](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/migrate-to-api/#observequery)) | +| `Amplify.DataStore.clear()` | No longer needed (no local DataStore to clear) | +| `Amplify.DataStore.start()` / `stop()` | No longer needed | + + + +Keep `amplify_auth_cognito` (or other auth plugins) if you still use Amplify for authentication or other services. You only need to remove the DataStore plugin and dependency. + + + + + +**`Amplify.DataStore.clear()` in sign-out flows:** If your sign-out flow calls `Amplify.DataStore.clear()` to wipe local data before signing out, remove that call entirely — there is no local DataStore to clear after migration. + + + + + + + +Before adding Apollo, remove Amplify DataStore from your project. + +## Remove DataStore frameworks from your target + +1. In the Xcode **Navigator panel** (left sidebar), select the **blue project icon** at the very top (the document icon labeled with your project name). This opens the **Project Editor**. +2. In the Project Editor, you will see two columns: **PROJECT** and **TARGETS**. Select your app name under **TARGETS** (not under PROJECT). +3. Select the **"General"** tab at the top. +4. Scroll down to the **"Frameworks, Libraries, and Embedded Content"** section (below "Supported Destinations" and "Minimum Deployments"). +5. Remove the DataStore-related frameworks by selecting each one and selecting the **⊖ (minus)** button at the bottom of the list. Remove at minimum: + - `AWSDataStorePlugin` + - `AWSAPIPlugin` (if it was only used by DataStore) + + + +A typical Amplify project may have many framework products linked beyond DataStore, such as `AWSCloudWatchLoggingPlugin`, `AWSLocationGeoPlugin`, `AWSPinpointAnalyticsPlugin`, `AWSPinpointPushNotificationsPlugin`, `AWSPredictionsPlugin`, `AWSS3StoragePlugin`, etc. Remove all frameworks that you are not actively using. **Keep** the frameworks you still need — for example, if you use Amplify for authentication, keep `Amplify`, `AWSCognitoAuthPlugin`, `AWSPluginsCore`, and `Authenticator` (if using the Amplify Authenticator SwiftUI component). + + + +## Remove the DataStore plugin from app initialization + +Remove the DataStore and API plugin registration from your Amplify configuration code: + +```swift +// Remove these lines: +try Amplify.add(plugin: AWSDataStorePlugin(modelRegistration: AmplifyModels())) +try Amplify.add(plugin: AWSAPIPlugin(modelRegistration: AmplifyModels())) +``` + +Keep any other plugins you still need (e.g., `AWSCognitoAuthPlugin`). + +## Delete DataStore generated model files + +If you used Amplify codegen, delete the generated model classes. These are typically found in a `Models/` directory and include files like: +- `Post.swift` +- `Post+Schema.swift` +- `AmplifyModels.swift` + +These will be replaced by Apollo's generated types. + +## Remove all `Amplify.DataStore.*` calls + +Search your codebase and remove or replace all DataStore calls: + +| DataStore Call | Apollo Replacement | +|---|---| +| `Amplify.DataStore.save()` | Apollo mutations (see [Migrate DataStore to Apollo](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/migrate-datastore-to-apollo/)) | +| `Amplify.DataStore.delete()` | Apollo mutations (see [Migrate DataStore to Apollo](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/migrate-datastore-to-apollo/)) | +| `Amplify.DataStore.query()` | Apollo queries (see [Migrate DataStore to Apollo](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/migrate-datastore-to-apollo/)) | +| `Amplify.DataStore.observe()` | Apollo subscriptions (see [Migrate DataStore to Apollo](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/migrate-datastore-to-apollo/)) | +| `Amplify.DataStore.observeQuery()` | Apollo cache watchers (see [Migrate DataStore to Apollo](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/migrate-datastore-to-apollo/#observequery)) | +| `Amplify.DataStore.clear()` | `apolloClient.store.clearCache()` | +| `Amplify.DataStore.start()` / `.stop()` | No longer needed | + + + +Keep `AWSCognitoAuthPlugin` (or other auth plugins) if you still use Amplify for authentication or other services. You only need to remove the DataStore plugin and the API plugin (if only used by DataStore). If you use the Amplify `Authenticator` SwiftUI view, keep `Authenticator` and `AWSCognitoAuthPlugin` — they work independently of DataStore. + + + + + + + +Before adding Apollo, remove Amplify DataStore from your project. + +## Remove the DataStore dependency + +Remove `com.amplifyframework:aws-datastore` (or its version catalog alias) from your `build.gradle.kts` (or `libs.versions.toml`). + +## Remove the DataStore plugin + +Remove the DataStore plugin from your `Application` class: + +```kotlin +// Remove this line from your Amplify.addPlugin() calls: +Amplify.addPlugin(AWSDataStorePlugin()) +``` + +## Delete DataStore generated model files + +If you used Amplify codegen, delete the generated model classes (for example, `Post.java`, `PostStatus.java`, `AmplifyModelProvider.java` under `com/amplifyframework/datastore/generated/model/`). These will be replaced by Apollo's generated types. + +## Remove all `Amplify.DataStore.*` calls + +Remove all DataStore API calls throughout your code: + +| DataStore Call | Apollo Replacement | +|---|---| +| `Amplify.DataStore.save()` | Apollo mutations | +| `Amplify.DataStore.delete()` | Apollo mutations | +| `Amplify.DataStore.query()` | Apollo queries | +| `Amplify.DataStore.observe()` | Apollo subscriptions | +| `Amplify.DataStore.observeQuery()` | Apollo cache watchers (see [Migrate DataStore to Apollo](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/migrate-datastore-to-apollo/#observequery)) | +| `Amplify.DataStore.clear()` | No longer needed (no local DataStore to clear) | +| `Amplify.DataStore.start()` / `stop()` | No longer needed | + + + +Keep `AWSApiPlugin` and `AWSCognitoAuthPlugin` (or other auth plugins) if you still use Amplify for authentication or other services. You only need to remove the DataStore plugin and dependency. + + + + + +**`Amplify.DataStore.clear()` in sign-out flows:** If your sign-out flow calls `Amplify.DataStore.clear()` to wipe local data before signing out, remove that call entirely — there is no local DataStore to clear after migration. + + + + diff --git a/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/schema-and-operations/index.mdx b/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/schema-and-operations/index.mdx new file mode 100644 index 00000000000..8dada7f84cb --- /dev/null +++ b/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/schema-and-operations/index.mdx @@ -0,0 +1,222 @@ +import { getCustomStaticPath } from '@/utils/getCustomStaticPath'; + +export const meta = { + title: 'Schema and GraphQL operations', + description: 'Retrieve your AppSync schema and define GraphQL operations for Apollo Kotlin code generation.', + platforms: [ + 'android', + 'swift' + ] +}; + +export const getStaticPaths = async () => { + return getCustomStaticPath(meta.platforms); +}; + +export function getStaticProps(context) { + return { + props: { + platform: context.params.platform, + meta + } + }; +} + + + +Apollo's code generation plugin reads your schema and GraphQL operation files to generate type-safe Kotlin code. This page covers how to retrieve your AppSync schema and define the GraphQL operations you need. + + + + + +The schema is used by Apollo's code generation tool to generate type-safe Swift code that helps you execute GraphQL operations. This page covers how to retrieve your AppSync schema and define the GraphQL operations you need. + + + +## Retrieve your AWS AppSync GraphQL schema + +### Using the AWS AppSync web console + +1. Navigate to your API on the [AWS AppSync console](https://console.aws.amazon.com/appsync/home) +2. On the left side, select **Schema** +3. Select the **Export Schema** dropdown and download the `schema.json` file + + +4. Place this file at `app/src/main/graphql/schema.json` in your project (or the location configured in your Apollo Gradle plugin — see [Apollo documentation](https://www.apollographql.com/docs/kotlin/advanced/plugin-recipes#specifying-the-schema-location)) + + + + + +4. Place this file in your project directory (e.g., alongside your `.xcodeproj`). + + + +### Using the AWS CLI + +1. Run the following command with the [AWS CLI](https://aws.amazon.com/cli/) to download the schema: + +```bash +aws appsync get-introspection-schema \ + --api-id \ + --format JSON \ + --region \ + schema.json +``` + +You can find your API ID in the [AWS AppSync console](https://console.aws.amazon.com/appsync/home) or by running: + +```bash +aws appsync list-graphql-apis --region +``` + + + +2. Place the downloaded `schema.json` at `app/src/main/graphql/schema.json` in your project. + + + + + +2. Place the downloaded `schema.json` in your project directory. + + + +## Prepare GraphQL operations (queries, mutations, subscriptions) + +### Import from Amplify CLI + +The Amplify CLI can auto-generate queries (`queries.graphql`), mutations (`mutations.graphql`), and subscriptions (`subscriptions.graphql`) for your Amplify API / AppSync projects. Follow the [Client code generation guide](https://docs.amplify.aws/gen1/android/tools/cli-legacy/client-codegen/) to generate these files. Once generated, add them to your project as directed by the [Apollo documentation](https://www.apollographql.com/docs/kotlin/advanced/plugin-recipes#specifying-the-schema-location). + + + +Using Amplify-generated files is a good way to get started, but we recommend writing your own queries to selectively limit the queried data to your use case and to use GraphQL fragments for model reuse across your application. + + + +### Write your own + +Place your GraphQL operations in `app/src/main/graphql/`, which is the default location the Apollo Gradle plugin looks for these files. When writing your own operations, the [AWS AppSync Query Editor](https://docs.aws.amazon.com/appsync/latest/devguide/console-tour.html#queries-editor) is helpful for testing. + +#### Fragment + +Create a [fragment](https://www.apollographql.com/docs/kotlin/essentials/fragments) to reuse in all of your operations: + +```graphql +# fragments.graphql + +fragment PostDetails on Post { + id + updatedAt + createdAt + title + content + status + rating + _version + _deleted + _lastChangedAt +} +``` + + + +**Include all fields your app uses.** Your fragment must include every field required by your mutations. For example, if `content` is a required field (`String!`) in your schema, you must include it in your fragment and mutation inputs — otherwise mutations will fail with a validation error. + + + + + +**DataStore conflict resolution fields:** If your AppSync API uses DataStore conflict resolution, your schema includes `_version`, `_deleted`, and `_lastChangedAt` fields. You **must** include `_version` in your fragment and pass it in update and delete mutations — otherwise mutations will fail with a `ConflictUnhandled` error. + + + +#### Mutations + +Create [mutations](https://www.apollographql.com/docs/kotlin/essentials/mutations) that modify or create posts. Using the `PostDetails` fragment returns the same data type in each mutation: + +```graphql +# mutations.graphql + +# Create +mutation CreatePost($input: CreatePostInput!) { + createPost(input: $input) { + ... PostDetails + } +} + +# Update +mutation UpdatePost($input: UpdatePostInput!) { + updatePost(input: $input) { + ... PostDetails + } +} + +# Delete +mutation DeletePost($input: DeletePostInput!) { + deletePost(input: $input) { + id + } +} +``` + +#### Queries + +Create [queries](https://www.apollographql.com/docs/kotlin/essentials/queries) that fetch `PostDetails` from AWS AppSync: + +```graphql +# queries.graphql + +# Single Item +query GetPost($id: ID!) { + getPost(id: $id) { + ... PostDetails + } +} + +# List Items +query GetPosts($nextToken: String) { + listPosts(nextToken: $nextToken) { + items { + ... PostDetails + } + nextToken + } +} +``` + +#### Subscriptions + +Create [subscriptions](https://www.apollographql.com/docs/kotlin/essentials/subscriptions) that notify the app when a post is created, updated, or deleted: + +```graphql +# subscriptions.graphql + +# Create +subscription onCreateSubscription { + onCreatePost { + ... PostDetails + } +} + +# Update +subscription onUpdateSubscription { + onUpdatePost { + ... PostDetails + } +} + +# Delete +subscription onDeleteSubscription { + onDeletePost { + id + } +} +``` + + + +**Generated class names:** Apollo Kotlin generates a class for each operation using the operation name from your `.graphql` file. For example, `subscription onCreateSubscription` generates a class named `OnCreateSubscription`. If you named it `subscription OnCreatePost`, the generated class would be `OnCreatePostSubscription` (Apollo appends `Subscription` / `Query` / `Mutation` to the class name if it is not already in the operation name). Check the generated code in `build/generated/source/apollo/` to confirm the class names. + + diff --git a/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/set-up-apollo-ios/index.mdx b/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/set-up-apollo-ios/index.mdx new file mode 100644 index 00000000000..90633cb6b5d --- /dev/null +++ b/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/set-up-apollo-ios/index.mdx @@ -0,0 +1,358 @@ +import { getCustomStaticPath } from '@/utils/getCustomStaticPath'; + +export const meta = { + title: 'Set up Apollo iOS', + description: 'Add Apollo iOS dependencies, install the CLI, run code generation, and configure the Apollo client with your AppSync endpoint and authentication.', + platforms: [ + 'swift' + ] +}; + +export const getStaticPaths = async () => { + return getCustomStaticPath(meta.platforms); +}; + +export function getStaticProps(context) { + return { + props: { + platform: context.params.platform, + meta + } + }; +} + +Read the Apollo iOS [Getting Started section](https://www.apollographql.com/docs/ios/get-started) for information about adding Apollo iOS to your project. + +## Add dependencies + +Follow these steps to add the required packages in Xcode: + +### Step 1: Add the Apollo iOS package + +1. In Xcode, go to **File → Add Package Dependencies...** +2. In the dialog that appears, paste the following URL into the search field at the top right: + +``` +https://github.com/apollographql/apollo-ios.git +``` + +3. Wait for Xcode to fetch the package metadata (this may take 10–30 seconds). +4. **Set the version constraint:** Select the version dropdown and choose **"Up to Next Major Version"**. Set the range to start from **`1.0.0`** with an upper bound of **`2.0.0`**. This ensures you get Apollo iOS 1.x, which is required by the AWS AppSync Apollo Extensions library. **Do not use Apollo iOS 2.x.** +5. Select **"Add Package"**. +6. In the product selection dialog, check the **"Apollo"** checkbox. Make sure your app target is selected. Select **"Add Package"**. + +### Step 2: Add the AWS AppSync Apollo Extensions package + +1. Go to **File → Add Package Dependencies...** +2. Paste the following URL: + +``` +https://github.com/aws-amplify/aws-appsync-apollo-extensions-swift.git +``` + +3. Set the version to **"Up to Next Major Version"** from **`1.0.0`**. +4. Select **"Add Package"**, select **"AWSAppSyncApolloExtensions"**, and add it to your target. + +### Step 3: Keep Amplify Auth (if needed) + +If you use Amplify for authentication, your existing `amplify-swift` package should already be in your project. Make sure **`AWSPluginsCore`** is added to your target's **Frameworks, Libraries, and Embedded Content** — it is needed for `AuthCognitoTokensProvider` when configuring the Apollo client with Cognito auth. To check: + +1. Select the **blue project icon** in the Navigator → select your app under **TARGETS** → **General** tab. +2. Scroll to **Frameworks, Libraries, and Embedded Content**. +3. If `AWSPluginsCore` is not listed, select the **+ (plus)** button and select it from the list. + +### Summary of packages + +| Package | URL | Version | Product to Add | +|---|---|---|---| +| Apollo iOS | `https://github.com/apollographql/apollo-ios.git` | `1.0.0..<2.0.0` | `Apollo` | +| AWS AppSync Apollo Extensions | `https://github.com/aws-amplify/aws-appsync-apollo-extensions-swift.git` | `1.0.0..<2.0.0` | `AWSAppSyncApolloExtensions` | +| Amplify Swift (if using Amplify Auth) | `https://github.com/aws-amplify/amplify-swift.git` | Keep existing | `AWSPluginsCore` | + +## Install the Apollo iOS CLI + +The Apollo iOS CLI (`apollo-ios-cli`) is used for code generation. It is **not** available via Homebrew. You can obtain it in one of these ways: + +1. **Download from GitHub Releases** (recommended): Go to [Apollo iOS Releases](https://github.com/apollographql/apollo-ios/releases), find the release matching your Apollo iOS library version, and download the `apollo-ios-cli.tar.gz` asset from the release assets. + +After downloading: + +```bash +# Extract the binary from the archive +tar -xzf apollo-ios-cli.tar.gz + +# Make the binary executable (macOS may require this) +chmod +x apollo-ios-cli + +# Move it to your project directory (same level as apollo-codegen-config.json) +mv apollo-ios-cli /path/to/your/project/ +``` + +2. **Build from source**: Clone the `apollo-ios` repo at the matching tag and build the CLI. + + + +**Version matching is critical.** The Apollo iOS CLI version **must exactly match** your resolved Apollo iOS library version. A mismatched CLI version produces Swift files that are incompatible with the library, causing compile errors such as `Type 'PostDetails' does not conform to protocol 'SelectionSet'` on every generated type. + +**To check your resolved library version:** +- In Xcode: **File → Packages → Resolved Versions** (or check `Package.resolved` in your `.xcworkspace/xcshareddata/swiftpm/` directory) +- Look for the `apollo-ios` entry and note the exact version (e.g., `1.25.3`) +- Download the CLI release with that **exact same version number** + +**Recommended workflow:** +1. Add Apollo iOS package to your project (Step 1 above) +2. Let Xcode/SPM resolve the package version +3. Check the resolved version +4. Download the matching Apollo iOS CLI version +5. Then proceed to code generation + + + +## Code generation + +Place your `schema.json` and `.graphql` operation files in a `graphql/` subdirectory of your project (you should have done this in the [Schema and GraphQL operations](/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/schema-and-operations/) page). Your project directory should look like: + +``` +YourProject/ +├── YourProject.xcodeproj +├── schema.json +├── apollo-ios-cli +├── apollo-codegen-config.json +├── graphql/ +│ ├── fragments.graphql +│ ├── mutations.graphql +│ ├── queries.graphql +│ └── subscriptions.graphql +└── YourProject/ + └── (your Swift source files) +``` + +You can either initialize the config and modify it, or create `apollo-codegen-config.json` directly. Here is the configuration you need: + +```json +{ + "schemaNamespace": "ApolloCodeGen", + "input": { + "operationSearchPaths": [ + "./graphql/fragments.graphql", + "./graphql/mutations.graphql", + "./graphql/queries.graphql", + "./graphql/subscriptions.graphql" + ], + "schemaSearchPaths": [ + "./schema.json" + ] + }, + "output": { + "testMocks": { + "none": {} + }, + "schemaTypes": { + "path": "./ApolloCodeGen", + "moduleType": { + "swiftPackageManager": {} + } + }, + "operations": { + "inSchemaModule": {} + } + } +} +``` + + + +If you prefer, you can generate an initial config using `./apollo-ios-cli init --schema-namespace ApolloCodeGen --module-type swiftPackageManager` and then modify the `operationSearchPaths` and `schemaSearchPaths` to match the paths above. The default config uses `**/*.graphqls` for `schemaSearchPaths`, but AppSync exports the schema as `schema.json`, so you must update the path. + + + +Generate the Swift files: + +```bash +./apollo-ios-cli generate +``` + +This creates an `ApolloCodeGen` directory containing a Swift package with all generated types. + +## Add generated code to your Xcode project + +After code generation, add the generated Swift package to your project: + +1. In Xcode, go to **File → Add Package Dependencies...** +2. At the **bottom-left** of the dialog, select the **"Add Local..."** button (this is not the URL search field at the top). +3. In the file picker, navigate to and select the generated `ApolloCodeGen` directory inside your project. +4. Xcode will recognize it as a local Swift package. Select **"Add Package"**. +5. Make sure the **ApolloCodeGen** library product is added to your app target. If it does not appear automatically, go to your target's **General** tab → **Frameworks, Libraries, and Embedded Content** → select **+ (plus)** → select **ApolloCodeGen** from the list. + + + +**Generated types and namespacing:** All generated types live inside the `ApolloCodeGen` module. When referencing generated types in your Swift code (such as enums), you must use the fully qualified name `ApolloCodeGen.PostStatus` or add `import ApolloCodeGen` at the top of your file. This is different from Amplify DataStore's generated models, which lived in your app module directly. For example: + +```swift +// Before (DataStore): +let status: PostStatus = .active + +// After (Apollo): +let status: ApolloCodeGen.PostStatus = .case(.active) +// or: import ApolloCodeGen, then use GraphQLEnum +``` + + + +## Configure the Apollo client + +The runtime component of Apollo must be configured to connect to AWS AppSync, including handling authorization modes and the subscription protocol. The [AWS AppSync Apollo Extensions library](https://docs.amplify.aws/swift/build-a-backend/data/aws-appsync-apollo-extensions/) implements the required logic. + +### With Amplify Gen 2 config (`amplify_outputs.json`) + +If your project uses Amplify Gen 2 (or you have converted your Gen 1 config — see the note below), you can use `AWSAppSyncConfiguration` to read the endpoint and configure authorization automatically. + +**For API Key auth:** + +```swift +let store = ApolloStore(cache: InMemoryNormalizedCache()) +// 1. Read AWS AppSync API configuration from amplify_outputs.json +let configuration = try AWSAppSyncConfiguration(with: .amplifyOutputs) + +// 2. Use configuration.apiKey with APIKeyAuthorizer +let authorizer = APIKeyAuthorizer(apiKey: configuration.apiKey ?? "") +let interceptor = AppSyncInterceptor(authorizer) +let interceptorProvider = DefaultPrependInterceptorProvider( + interceptor: interceptor, + store: store) + +// 3. Use configuration.endpoint with RequestChainNetworkTransport +let transport = RequestChainNetworkTransport( + interceptorProvider: interceptorProvider, + endpointURL: configuration.endpoint) +let apolloClient = ApolloClient( + networkTransport: transport, + store: store) +``` + +**For Cognito User Pool auth (owner-based authorization):** + +```swift +import AWSPluginsCore + +let store = ApolloStore(cache: InMemoryNormalizedCache()) +let configuration = try AWSAppSyncConfiguration(with: .amplifyOutputs) + +let authorizer = AuthTokenAuthorizer { + let session = try await Amplify.Auth.fetchAuthSession() + if let tokenProvider = session as? AuthCognitoTokensProvider { + let tokens = try tokenProvider.getCognitoTokens().get() + return tokens.accessToken + } + throw AuthError.unknown("Unable to get Cognito tokens") +} + +let interceptor = AppSyncInterceptor(authorizer) +let interceptorProvider = DefaultPrependInterceptorProvider( + interceptor: interceptor, + store: store) +let transport = RequestChainNetworkTransport( + interceptorProvider: interceptorProvider, + endpointURL: configuration.endpoint) +let apolloClient = ApolloClient( + networkTransport: transport, + store: store) +``` + + + +`AuthCognitoTokensProvider` requires importing **`AWSPluginsCore`** from the `amplify-swift` package. Make sure to add this library product to your target. + + + + + +**Gen 1 to Gen 2 config conversion:** If your project uses Amplify Gen 1 (`amplifyconfiguration.json`), `AWSAppSyncConfiguration(with: .amplifyOutputs)` will **not** work with it directly — it requires the Gen 2 format (`amplify_outputs.json`). You can generate `amplify_outputs.json` from your existing Gen 1 backend using the `ampx` CLI: + +```bash +npx ampx generate outputs \ + --stack \ + --out-dir . \ + --outputs-version 1 +``` + +Find your stack name in `amplify/backend/amplify-meta.json` (under `providers.awscloudformation.StackName`) or in the AWS CloudFormation console. Alternatively, use the Amplify App ID: + +```bash +npx ampx generate outputs \ + --app-id \ + --branch \ + --out-dir . \ + --outputs-version 1 +``` + +The `--outputs-version 1` flag ensures the correct format is generated. After running this command, add the generated `amplify_outputs.json` to your Xcode project. + + + +### Without Amplify (or keeping Gen 1 config) + +If you prefer not to convert your config format (for example, if you want to keep using your existing Gen 1 `amplifyconfiguration.json` for Amplify Auth), you can configure the Apollo client manually: + +**For API Key auth:** + +```swift +let store = ApolloStore(cache: InMemoryNormalizedCache()) +let authorizer = APIKeyAuthorizer(apiKey: "") + +let interceptor = AppSyncInterceptor(authorizer) +let interceptorProvider = DefaultPrependInterceptorProvider( + interceptor: interceptor, + store: store) +let transport = RequestChainNetworkTransport( + interceptorProvider: interceptorProvider, + endpointURL: URL(string: "")!) +let apolloClient = ApolloClient( + networkTransport: transport, + store: store) +``` + +**For Cognito User Pool auth (using Amplify Auth for token fetching):** + +If you use Amplify for Cognito authentication but configure Apollo manually, you need to provide the endpoint URL from your AppSync configuration and use `AuthCognitoTokensProvider` to get the auth token: + +```swift +import AWSPluginsCore + +let store = ApolloStore(cache: InMemoryNormalizedCache()) + +// Get your endpoint URL from amplifyconfiguration.json or the AppSync console +let endpointURL = URL(string: "")! + +let authorizer = AuthTokenAuthorizer { + let session = try await Amplify.Auth.fetchAuthSession() + if let tokenProvider = session as? AuthCognitoTokensProvider { + let tokens = try tokenProvider.getCognitoTokens().get() + return tokens.accessToken + } + throw AuthError.unknown("Unable to get Cognito tokens") +} + +let interceptor = AppSyncInterceptor(authorizer) +let interceptorProvider = DefaultPrependInterceptorProvider( + interceptor: interceptor, + store: store) +let transport = RequestChainNetworkTransport( + interceptorProvider: interceptorProvider, + endpointURL: endpointURL) +let apolloClient = ApolloClient( + networkTransport: transport, + store: store) +``` + + + +You can find your AppSync endpoint URL in `amplifyconfiguration.json` under `api..endpoint`, or in the [AWS AppSync console](https://console.aws.amazon.com/appsync/home) under your API's settings. + + + + + +**Which approach should you use?** If you are migrating a Gen 1 project and do not want to deal with config conversion, the manual endpoint approach is the simplest path — you keep `amplifyconfiguration.json` for Amplify Auth and point Apollo at your AppSync endpoint directly. If you prefer a single unified config, convert to `amplify_outputs.json` using the `ampx` CLI (see the Gen 2 Config section above). + + diff --git a/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/set-up-apollo-kotlin/index.mdx b/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/set-up-apollo-kotlin/index.mdx new file mode 100644 index 00000000000..fc3b7f33b16 --- /dev/null +++ b/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/set-up-apollo-kotlin/index.mdx @@ -0,0 +1,254 @@ +import { getCustomStaticPath } from '@/utils/getCustomStaticPath'; + +export const meta = { + title: 'Set up Apollo Kotlin', + description: 'Add Apollo Kotlin dependencies, configure the Gradle plugin, and set up the Apollo client with your AppSync endpoint and authentication.', + platforms: [ + 'android' + ] +}; + +export const getStaticPaths = async () => { + return getCustomStaticPath(meta.platforms); +}; + +export function getStaticProps(context) { + return { + props: { + platform: context.params.platform, + meta + } + }; +} + +Apollo Kotlin includes two main components: a Gradle plugin that reads your schema and operation files to generate type-safe Kotlin code, and a runtime client that executes requests using the generated code. This page covers adding dependencies, configuring the Gradle plugin, and setting up the Apollo client. + +## Add dependencies + +Add the following to your version catalog (`gradle/libs.versions.toml`): + +```toml +[versions] +apollo = "4.1.0" +apolloAppsync = "1.0.0" + +[libraries] +apollo-runtime = { group = "com.apollographql.apollo", name = "apollo-runtime", version.ref = "apollo" } +apollo-appsync = { group = "com.amplifyframework", name = "apollo-appsync", version.ref = "apolloAppsync" } +apollo-appsync-amplify = { group = "com.amplifyframework", name = "apollo-appsync-amplify", version.ref = "apolloAppsync" } + +[plugins] +apollo = { id = "com.apollographql.apollo", version.ref = "apollo" } +``` + + + +You need **two** AppSync Apollo Extensions dependencies: +- `com.amplifyframework:apollo-appsync` — provides core classes: `AppSyncEndpoint`, `AppSyncInterceptor`, and authorizer types (`ApiKeyAuthorizer`, `AuthTokenAuthorizer`, `IamAuthorizer`) +- `com.amplifyframework:apollo-appsync-amplify` — provides Amplify integration: `ApolloAmplifyConnector` for reading Amplify config and fetching auth tokens + +The base `apollo-appsync` library is **not** automatically exposed as a transitive dependency of `apollo-appsync-amplify`, so you must add both explicitly. + + + +Add the Apollo plugin to your root `build.gradle.kts`: + +```kotlin +plugins { + // ... existing plugins + alias(libs.plugins.apollo) apply false +} +``` + +Add the plugin and dependencies to your app `build.gradle.kts`: + +```kotlin +plugins { + // ... existing plugins + alias(libs.plugins.apollo) +} + +dependencies { + // Apollo Kotlin + implementation(libs.apollo.runtime) + + // AWS AppSync Apollo Extensions + implementation(libs.apollo.appsync) + implementation(libs.apollo.appsync.amplify) + + // Keep your existing Amplify Auth dependencies (if using Amplify for auth) + // implementation(libs.amplify.auth.cognito) + // implementation(libs.amplify.api) +} +``` + +## Configure the Apollo Gradle plugin + +Read the Apollo Kotlin [Getting Started section](https://www.apollographql.com/docs/kotlin#getting-started) for more information, and the [Gradle Plugin configuration](https://www.apollographql.com/docs/kotlin/advanced/plugin-configuration) documentation for all configuration options. + +Your Apollo configuration should look similar to this: + +```kotlin +apollo { + service("example") { + packageName.set("com.example.appsync") + schemaFile.set(file("src/main/graphql/schema.json")) + + // Map AppSync custom scalars to Kotlin String. + // Without this, fields like createdAt and updatedAt will be + // generated as `Any` instead of `String`. + // IMPORTANT: Only include scalars that are actually used in your schema. + // Apollo will error on unknown scalars. Check your downloaded schema.json + // to see which custom scalars your API uses. + mapScalarToKotlinString("AWSDateTime") + mapScalarToKotlinString("AWSTimestamp") + // Add any of the following only if your schema uses them: + // mapScalarToKotlinString("AWSDate") + // mapScalarToKotlinString("AWSTime") + // mapScalarToKotlinString("AWSEmail") + // mapScalarToKotlinString("AWSJSON") + // mapScalarToKotlinString("AWSURL") + // mapScalarToKotlinString("AWSPhone") + // mapScalarToKotlinString("AWSIPAddress") + } +} +``` + + + +**Custom scalars:** AWS AppSync uses custom GraphQL scalar types (such as `AWSDateTime`, `AWSJSON`). By default, Apollo generates these as `Any` in Kotlin, which means fields like `createdAt` and `updatedAt` will have type `Any` instead of `String`. Use `mapScalarToKotlinString()` to map them to `String`. Only map scalars that exist in your downloaded `schema.json` — Apollo 4.x will fail with an "unknown scalar" error if you map a scalar not present in the schema. A typical DataStore-backed AppSync schema uses `AWSDateTime` and `AWSTimestamp`. See the [AppSync Apollo Extensions type mapping docs](https://docs.amplify.aws/android/build-a-backend/data/aws-appsync-apollo-extensions/#type-mapping-appsync-scalars) for more details. + + + + + +**Scalar type generation:** In some Apollo versions, `mapScalarToKotlinString()` may not fully propagate to fragment fields — fields like `createdAt` and `updatedAt` may still be generated as `Any` in the `PostDetails` fragment class. At runtime, these values are strings, so you can safely use `.toString()` to work with them (for example, `post.createdAt?.toString()`). Check the generated code in `build/generated/source/apollo/` to verify the actual types. + + + + + +**Generated enum types:** Apollo Kotlin generates GraphQL enum types as **Kotlin sealed classes** rather than Java-style enums. Standard Java enum methods like `.values()` and `.valueOf()` will **not** work. Use `.knownEntries` instead: + +```kotlin +// This will NOT compile — Apollo enums are not Java enums +PostStatus.values() + +// Use this instead +PostStatus.knownEntries +``` + +If your UI code uses `.values()` to populate dropdowns or pickers, update all occurrences to `.knownEntries`. + + + +## Configure the Apollo client + +The runtime component of Apollo must be configured to connect to AWS AppSync, including handling authorization modes and the subscription protocol. The [AWS AppSync Apollo Extensions library](https://docs.amplify.aws/android/build-a-backend/data/aws-appsync-apollo-extensions/) implements the required logic. + +### With Amplify Gen 2 config (`amplify_outputs.json`) + +If your project uses Amplify Gen 2 (or you have converted your Gen 1 config — see the note below), you can use `ApolloAmplifyConnector` to read the endpoint and configure authorization automatically. + +**For API Key auth:** + +```kotlin +val connector = ApolloAmplifyConnector(context, AmplifyOutputs(R.raw.amplify_outputs)) +val apolloClient = ApolloClient.Builder() + .serverUrl(connector.endpoint.serverUrl.toString()) + .addHttpInterceptor(AppSyncInterceptor(connector.apiKeyAuthorizer())) + .build() +``` + +**For Cognito User Pool auth (owner-based authorization):** + +```kotlin +val connector = ApolloAmplifyConnector(context, AmplifyOutputs(R.raw.amplify_outputs)) +val apolloClient = ApolloClient.Builder() + .serverUrl(connector.endpoint.serverUrl.toString()) + .addHttpInterceptor(AppSyncInterceptor(connector.cognitoUserPoolAuthorizer())) + .build() +``` + + + +**Gen 1 to Gen 2 config conversion:** If your project uses Amplify Gen 1 (`amplifyconfiguration.json`), `ApolloAmplifyConnector` will **not** work with it directly — it requires the Gen 2 format (`amplify_outputs.json`). You can generate `amplify_outputs.json` from your existing Gen 1 backend using the `ampx` CLI: + +```bash +npx ampx generate outputs \ + --stack \ + --out-dir app/src/main/res/raw \ + --outputs-version 1 +``` + +Find your stack name in `amplify/backend/amplify-meta.json` or in the AWS CloudFormation console. Alternatively, use the Amplify App ID: + +```bash +npx ampx generate outputs \ + --app-id \ + --branch \ + --out-dir app/src/main/res/raw \ + --outputs-version 1 +``` + +The `--outputs-version` flag controls the output format: version `0` produces the classic `amplify-configuration` format, while version `1` produces the newer `amplify_outputs` format needed by `ApolloAmplifyConnector`. After running this command, place the generated `amplify_outputs.json` in `app/src/main/res/raw/`. + + + +### With Amplify Gen 1 config (`amplifyconfiguration.json`) — manual endpoint + +If you want to keep using your existing Gen 1 `amplifyconfiguration.json` for Amplify Auth without converting config formats, you can configure the Apollo client manually. You are still using Amplify (for authentication) — only the Apollo endpoint configuration is done manually. + +**For API Key auth:** + +```kotlin +val endpoint = AppSyncEndpoint("") +val authorizer = ApiKeyAuthorizer("") +val apolloClient = ApolloClient.Builder() + .serverUrl(endpoint.serverUrl.toString()) + .addHttpInterceptor(AppSyncInterceptor(authorizer)) + .build() +``` + +**For Cognito User Pool auth (using Amplify Auth for token fetching):** + +If you are still using Amplify for Cognito authentication but configuring Apollo manually, you can use `ApolloAmplifyConnector.fetchLatestCognitoAuthToken` to get the auth token. This method uses **callback-based** Java `Consumer` parameters, not Kotlin coroutines, so you need to wrap it with `suspendCoroutine`: + +```kotlin +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +val endpoint = AppSyncEndpoint("") +val authorizer = AuthTokenAuthorizer { + suspendCoroutine { cont -> + ApolloAmplifyConnector.fetchLatestCognitoAuthToken( + { token -> cont.resume(token) }, + { error -> cont.resumeWithException(error) } + ) + } +} + +val apolloClient = ApolloClient.Builder() + .serverUrl(endpoint.serverUrl.toString()) + .addHttpInterceptor(AppSyncInterceptor(authorizer)) + .build() +``` + + + +**Which approach should you use?** If you are migrating a Gen 1 project and do not want to deal with config conversion, the manual endpoint approach is the simplest path — you keep `amplifyconfiguration.json` for Amplify Auth and point Apollo at your AppSync endpoint directly. If you prefer a single unified config, convert to `amplify_outputs.json` using the `ampx` CLI (see the Gen 2 Config section above). + + + + + +**Finding your AppSync endpoint:** You can find your AppSync endpoint URL in the [AWS AppSync console](https://console.aws.amazon.com/appsync/home) under your API's **Settings** page, or in your `amplifyconfiguration.json` under `api > plugins > awsAPIPlugin > > endpoint`. + + + + + +**Import note:** `AppSyncEndpoint` and `AppSyncInterceptor` are in `com.amplifyframework.apollo.appsync`. `AuthTokenAuthorizer` and `ApiKeyAuthorizer` are in `com.amplifyframework.apollo.appsync.authorizers`. `ApolloAmplifyConnector` is in `com.amplifyframework.apollo.appsync` (provided by the `apollo-appsync-amplify` artifact). + + From d36ffbae7d2db87070a6027448e9b3a3e412228a Mon Sep 17 00:00:00 2001 From: Michael Sober Date: Thu, 30 Apr 2026 13:49:48 +0200 Subject: [PATCH 3/4] docs: add missing link to Apollo docs --- .../more-features/datastore/migrate-from-datastore/index.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/index.mdx b/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/index.mdx index e0865b6afef..b68798465e5 100644 --- a/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/index.mdx +++ b/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/index.mdx @@ -41,7 +41,7 @@ DataStore abstracted away the complexity of GraphQL operations, network state ma -This guide shows you how to use Apollo Client for queries, mutations, and caching, combined with the Amplify library's built-in subscription support for real-time updates. Depending on how much of DataStore's feature set your app actually uses, you may find the migration simpler than expected. +This guide shows you how to use [Apollo Client](https://www.apollographql.com/docs/react) for queries, mutations, and caching, combined with the Amplify library's built-in subscription support for real-time updates. Depending on how much of DataStore's feature set your app actually uses, you may find the migration simpler than expected. From 2eeb2f9ebfc54e5dc27b3c5a9c5650bad1678699 Mon Sep 17 00:00:00 2001 From: Jonas Greifenhain Date: Thu, 30 Apr 2026 14:32:53 +0200 Subject: [PATCH 4/4] docs: add missing Swift sections --- .../offline-first/index.mdx | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) diff --git a/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/offline-first/index.mdx b/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/offline-first/index.mdx index 81bf6592e19..4e099392464 100644 --- a/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/offline-first/index.mdx +++ b/src/pages/gen1/[platform]/build-a-backend/more-features/datastore/migrate-from-datastore/offline-first/index.mdx @@ -57,6 +57,31 @@ We understand the importance of these capabilities to your applications, and whi By regularly synchronizing your local store, it can serve as the primary source for your application, allowing for read operations that can be done offline. Opting to manage offline writes will introduce additional complexity to your application, so it is crucial to consider whether you want to handle this for each individual model. + + +## Cascade delete + +SwiftData supports cascade delete for models with relationships and provides different options to handle deletions. Relationships can be defined using [`Relationship(_:deleteRule:minimumModelCount:maximumModelCount:originalName:inverse:hashModifier:)`](https://developer.apple.com/documentation/swiftdata/relationship(_:deleterule:minimummodelcount:maximummodelcount:originalname:inverse:hashmodifier:)) modifier. + +```swift +@Model +class Trip { + var name: String + var destination: String + var startDate: Date + var endDate: Date + var accommodation: Accommodation? +} +``` + +Deletion rule can be set using [DeleteRule](https://developer.apple.com/documentation/swiftdata/schema/relationship/deleterule-swift.enum). + +```swift +@Relationship(.cascade) var accommodation: Accommodation? +``` + + + ## Live syncing @@ -130,6 +155,49 @@ suspend fun startSubscriptions() = supervisorScope { + + +You can achieve real-time detection of changes (create, update, delete) to a remote repository by leveraging AWS AppSync, as long as you are connected to the internet. The following snippet subscribes to remote creation, updates, and deletion of our `Post` type, and propagates those changes into our SwiftData local store. These subscriptions will need to be established each time your application reconnects to the internet. + +```swift +let createSubscription = apolloClient.subscribe( + subscription: OnCreateSubscriptionSubscription() +) { result in + guard let data = try? result.get().data else { return } + if let postDetails = data.onCreatePost?.fragments.postDetails { + let model = PostEntity(postDetails: postDetails) + context.insert(model) + try? context.save() + } +} + +let deleteSubscription = apolloClient.subscribe( + subscription: OnDeleteSubscriptionSubscription() +) { result in + guard let data = try? result.get().data else { return } + if let deletedId = data.onDeletePost?.id { + let fetchDescriptor = FetchDescriptor( + predicate: #Predicate { $0.id == deletedId }) + if let post = try? context.fetch(fetchDescriptor).first { + context.delete(post) + } + } +} + +let updateSubscription = apolloClient.subscribe( + subscription: OnUpdateSubscriptionSubscription() +) { result in + guard let data = try? result.get().data else { return } + if let postDetails = data.onUpdatePost?.fragments.postDetails { + let model = PostEntity(postDetails: postDetails) + context.insert(model) // upsert via @Attribute(.unique) on id + try? context.save() + } +} +``` + + + ## Local cache refresh Whenever your app reconnects to the internet, whether through launching the app or network updates, perform a complete refresh of your local store to ensure that you receive all the updates that you may have missed. @@ -173,6 +241,33 @@ If you often sync large amounts of data, configuring AWS AppSync Delta Sync oper + + +```swift +func syncAllPosts(apolloClient: ApolloClient, context: ModelContext) { + var nextToken: String? = nil + repeat { + let query = GetPostsQuery(nextToken: nextToken.flatMap { .some($0) } ?? .none) + apolloClient.fetch(query: query) { result in + guard let data = try? result.get().data else { return } + if let items = data.listPosts?.items { + for item in items { + guard let postDetails = item?.fragments.postDetails else { continue } + let model = PostEntity(postDetails: postDetails) + context.insert(model) + try? context.save() + } + } + nextToken = data.listPosts?.nextToken + } + } while (nextToken != nil) +} +``` + +If you often sync large amounts of data, configuring AWS AppSync Delta Sync operations can reduce the amount of time it takes to refresh your local store. Read more about how to configure and implement Delta Sync functionality in the [AWS AppSync Delta Sync guide](https://docs.aws.amazon.com/appsync/latest/devguide/tutorial-delta-sync.html). + + + ## Detect network status @@ -305,6 +400,42 @@ class SyncEngine(val networkMonitor: NetworkMonitor) { + + +You can proactively detect network status using `NWPathMonitor`. This allows you to identify scenarios where your local cache needs to be synced and subscriptions need to be reestablished. + +```swift +protocol NetworkMonitor: AnyObject { + var isOnline: Bool { get } + func startMonitoring(using queue: DispatchQueue) + func stopMonitoring() +} + +extension NWPathMonitor: NetworkMonitor { + var isOnline: Bool { + currentPath.status == .satisfied + } + + func startMonitoring(using queue: DispatchQueue) { + self.pathUpdateHandler = { [weak self] path in + let isConnected = path.status == .satisfied + if isConnected { + // start sync and reestablish subscriptions + } else { + // stop sync + } + } + start(queue: queue) + } + + func stopMonitoring() { + cancel() + } +} +``` + + + ## Offline mutations By storing any changes made locally in a separate pending mutations table and synchronizing them at a later time, you can enable your users to work offline. Your pending mutations table should contain: @@ -391,3 +522,66 @@ fun PostMutationEntity.applyTo(post: PostEntity) = post.copy( ``` + + + + + +Offline mutations can introduce significant complexity to your application. Synchronization, reconciliation, and conflict resolution can each be a non-trivial problem. You should carefully consider the pros and cons before deciding to support offline mutations. + + + +As an example, a pending mutation for our sample `Post` type might look like the following: + +```swift +enum MutationType: Codable { + case create + case update + case delete +} + +@Model +class PostMutationEntity { + var postId: String + var type: MutationType + var title: String? + var content: String? + var status: PostEntityStatus? + var rating: Int? + var timestamp: String + + init(postId: String, + type: MutationType, + title: String? = nil, + content: String? = nil, + status: PostEntityStatus? = nil, + rating: Int? = nil, + timestamp: String) { + self.postId = postId + self.type = type + self.title = title + self.content = content + self.status = status + self.rating = rating + self.timestamp = timestamp + } +} +``` + +### Apply pending mutations + +These pending mutations can be synced to AWS AppSync when the application has connectivity. To incorporate pending mutation table entries into your app before syncing, you must aggregate them with the results of your local store queries. In our example you can fetch both the `PostEntity` and `PostMutationEntity` instances, and conflate the two into a single display model, optimistically applying the mutation before it is processed by AWS AppSync. + +```swift +extension PostMutationEntity { + func applyTo(model: PostEntity) -> PostEntity { + if let title = self.title { model.title = title } + if let content = self.content { model.content = content } + if let rating = self.rating { model.rating = rating } + if let status = self.status { model.status = status } + return model + } +} +``` + +