aws-amplify / amplify-js

A declarative JavaScript library for application development using cloud services.
https://docs.amplify.aws/lib/q/platform/js
Apache License 2.0
9.41k stars 2.11k forks source link

DataStore React Native: updating a record before deleting results in repeated failed requests due to a conflict. #11708

Open duckbytes opened 1 year ago

duckbytes commented 1 year ago

Before opening, please confirm:

JavaScript Framework

React Native

Amplify APIs

GraphQL API, DataStore

Amplify Categories

auth, api

Environment information

``` System: OS: Linux 6.4 Arch Linux CPU: (16) x64 AMD Ryzen 7 3700X 8-Core Processor Memory: 10.35 GB / 31.26 GB Container: Yes Shell: 5.9 - /bin/zsh Binaries: Node: 16.13.2 - ~/.nvm/versions/node/v16.13.2/bin/node Yarn: 1.22.19 - /usr/bin/yarn npm: 8.1.2 - ~/.nvm/versions/node/v16.13.2/bin/npm npmPackages: @aws-amplify/datastore-storage-adapter: ^2.0.42 => 2.0.42 @aws-amplify/ui-react-native: ^1.2.20 => 1.2.20 @azure/core-asynciterator-polyfill: ^1.0.2 => 1.0.2 @babel/core: ^7.20.0 => 7.22.9 (7.9.0) @expo/webpack-config: ^18.0.1 => 18.1.1 @react-native-async-storage/async-storage: 1.17.11 => 1.17.11 @react-native-community/netinfo: 9.3.7 => 9.3.7 @react-navigation/bottom-tabs: ^6.5.8 => 6.5.8 @react-navigation/material-bottom-tabs: ^6.2.16 => 6.2.16 @react-navigation/native-stack: ^6.9.13 => 6.9.13 @reduxjs/toolkit: ^1.9.5 => 1.9.5 @reduxjs/toolkit-query: 1.0.0 @reduxjs/toolkit-query-react: 1.0.0 @testing-library/jest-native: ^5.4.2 => 5.4.2 @testing-library/react-native: ^12.1.3 => 12.1.3 @types/jest: ^29.5.3 => 29.5.3 @types/react: ~18.0.14 => 18.0.38 HelloWorld: 0.0.1 amazon-cognito-identity-js: ^6.3.1 => 6.3.1 amazon-cognito-identity-js/internals: undefined () aws-amplify: ^5.3.6 => 5.3.6 core-js: ^3.31.1 => 3.31.1 experiments-app: 1.0.0 expo: ~48.0.18 => 48.0.20 expo-blur: ~12.2.2 => 12.2.2 expo-clipboard: ~4.1.2 => 4.1.2 expo-file-system: ~15.2.2 => 15.2.2 (15.3.0, 11.1.3) expo-haptics: ~12.2.1 => 12.2.1 expo-sqlite: ~11.1.1 => 11.1.1 expo-status-bar: ~1.4.4 => 1.4.4 faker: 5.5.3 => 5.5.3 jest-expo: ^49.0.0 => 49.0.0 mock-async-storage: ^2.2.0 => 2.2.0 moment: ^2.29.4 => 2.29.4 moment-timezone: ^0.5.43 => 0.5.43 react: 18.2.0 => 18.2.0 react-content-loader: ^6.2.1 => 6.2.1 react-content-loader/native: undefined () react-native: 0.71.8 => 0.71.8 react-native-dotenv: ^3.4.9 => 3.4.9 react-native-gesture-handler: ~2.9.0 => 2.9.0 react-native-get-random-values: ~1.8.0 => 1.8.0 react-native-hold-menu: ^0.1.6 => 0.1.6 react-native-paper: ^5.9.1 => 5.9.1 react-native-paper-dates: ^0.18.12 => 0.18.12 react-native-reanimated: ~2.14.4 => 2.14.4 react-native-safe-area-context: 4.5.0 => 4.5.0 react-native-screens: ~3.20.0 => 3.20.0 react-native-svg: 13.4.0 => 13.4.0 react-native-testing-library-website: 0.0.0 react-native-unimodules: ^0.14.10 => 0.14.10 react-native-url-polyfill: ^2.0.0 => 2.0.0 (1.3.0) react-native-web: ~0.18.10 => 0.18.12 react-navigation-example: 0.0.1 react-redux: ^8.1.1 => 8.1.1 redux-example: 0.0.1 redux-saga: ^1.2.3 => 1.2.3 redux-saga/effects: undefined () typescript: ^4.9.4 => 4.9.5 npmGlobalPackages: @aws-amplify/cli: 11.0.3 @bubblewrap/cli: 1.18.1 @ionic/cli: 6.20.3 amplify-cli: 1.0.0 cordova: 11.1.0 corepack: 0.10.0 create-react-native-app: 3.9.0 deadfile: 2.0.1 docsify-cli: 4.4.4 graphql-language-service-cli: 3.3.16 jsonminify: 0.4.2 mjson: 0.4.2 native-run: 1.7.1 npm: 8.1.2 prebuild-install: 7.1.1 react_app: 0.1.0 react-dom: 17.0.2 react-js-to-ts: 1.4.0 react: 18.2.0 serve: 14.2.0 ts-node: 10.9.1 typescript: 4.8.2 uglify-js: 3.17.4 uglifyjs: 2.4.11 ```

Describe the bug

I would like to modify a record before deleting it with DataStore. In my case, I have a Comment model, and I would like to strip the body before deleting it. I am using optimistic concurrency.

If I only delete the record, everything works fine. If I modify the record before deleting it, I get conflict errors that are never resolved.

On the web version of my app, I was able to work around this issue by returning the remote model in the conflict resolver. This is using amplify 4.3.46. I'm now developing a mobile version with amplify 5.3.6 using the same backend. The workaround no longer seems to work, and DataStore continually tries to send the mutation.

However the data on the backend does seem to update successfully as reflected in dynamodb.

I thought a better solution to this problem would be to instead delete the body during the delete mutation with a custom override, but I wasn't able to figure out how to do it.

I'm not able to look at the actual network data as it seems to be quite difficult in React Native, but here are the responses I see in the web version, which complete successfully after one failed attempt:

Screenshot from 2023-08-02 21-48-21 Screenshot from 2023-08-02 21-48-28 Screenshot from 2023-08-02 21-48-43 Screenshot from 2023-08-02 21-48-50 Screenshot from 2023-08-02 21-48-59 Screenshot from 2023-08-02 21-49-05

Expected behavior

I would expect datastore to not continually try to send mutations after deleting a record.

Reproduction steps

  1. create a project with expo
  2. npx expo install @aws-amplify/datastore-storage-adapter @aws-amplify/ui-react-native @azure/core-asynciterator-polyfill @react-native-async-storage/async-storage amazon-cognito-identity-js expo-sqlite
  3. use Authenticator component
  4. initialise amplify
  5. create a graphql API with DataStore enabled and select optimistic concurrency

Code Snippet

My model in the schema:

type Comment
@auth(rules: [
  {allow: groups, groups: ["USER"], operations: [read]},
  {allow: owner, operations: [create, read, delete, update]},
  {allow: groups, groups: ["ADMIN"], operations: [create, read, delete, update]},
])
@model {
  id: ID!
  parentId: ID @index(name: "byParent")
  owner: String
    @auth(rules: [
      {allow: groups, groups: ["USER"], operations: [read]},
      {allow: owner, operations: [create, read, delete]},
      {allow: groups, groups: ["ADMIN"], operations: [create, read, delete]},
    ])
  tenantId: ID! @index(name: "byTenantId")
    @auth(rules: [
      {allow: groups, groups: ["USER"], operations: [read]},
      {allow: owner, operations: [create, read, delete]},
      {allow: groups, groups: ["ADMIN"], operations: [create, read, delete]},
    ])
  body: String
  author: User @belongsTo
  visibility: CommentVisibility
  archived: Int @default(value: "0") @index(name: "byArchived")
    @auth(rules: [
      {allow: groups, groups: ["USER"], operations: [read]},
      {allow: owner, operations: [create, read, delete]},
      {allow: groups, groups: ["ADMIN"], operations: [create, read, delete]},
    ])
}

App.tsx

import * as React from "react";
import "@azure/core-asynciterator-polyfill";
import { DataStore } from "aws-amplify";
import { ExpoSQLiteAdapter } from "@aws-amplify/datastore-storage-adapter/ExpoSQLiteAdapter";
import { Authenticator } from "@aws-amplify/ui-react-native";
import { Amplify } from "aws-amplify";
import config from "./src/aws-exports";

Amplify.configure(config);

DataStore.configure({
    storageAdapter: ExpoSQLiteAdapter,
});

const App = () => {    
        return (
            <Authenticator.Provider>
                <Authenticator loginMechanisms={["email"]}>
                            <Main />
                </Authenticator>
            </Authenticator.Provider>
        );
};

export default App;

deleteComment function

    const deleteComment = async () => {
        try {
            if (selectedComment) {
                const existingComment = await DataStore.query(
                    models.Comment,
                    selectedComment.id
                );
                if (existingComment) {
                    // if this part is removed, everything works
                    const updated = await DataStore.save(
                        models.Comment.copyOf(existingComment, (upd) => {
                            upd.body = "";
                        })
                    );
                    await DataStore.delete(updated);
                }
            }
        } catch (e) {
            console.log(e);
        }

    };

Conflict resolver:

import { DISCARD } from "@aws-amplify/datastore";
import {
    SyncConflict,
    PersistentModel,
    PersistentModelConstructor,
} from "@aws-amplify/datastore";
import * as models from "../../models";
import determineTaskStatus from "../../utilities/determineTaskStatus";

const dataStoreConflictHandler = async (
    conflict: SyncConflict
): Promise<symbol | PersistentModel> => {
    const { modelConstructor, localModel, remoteModel } = conflict;
    console.log(
        "DataStore has found a conflict",
        modelConstructor,
        remoteModel,
        localModel
    );

     if (
        modelConstructor ===
        (models.Comment as PersistentModelConstructor<models.Comment>)
    ) {
        return remoteModel;
    }
    return DISCARD;
};

export default dataStoreConflictHandler;

DataStore.configure (run in redux-saga)

                yield call([DataStore, DataStore.configure], {
                    errorHandler: (err) => {
                        console.log("DataStore error:", err);
                        console.log("Cause:", err.cause);
                    },
                    syncExpressions: [
                        ...modelsToSync.map((model) =>
                            syncExpression(
                                model,
                                () => (m) => m.tenantId.eq(tenantId)
                            )
                        ),
                        ...archivedModels.map((model) =>
                            syncExpression(
                                model,
                                () => (m) =>
                                    m.and((m) => [
                                        m.tenantId.eq(tenantId),
                                        m.archived.eq(0),
                                    ])
                            )
                        ),
                        syncExpression(
                            models.Tenant,
                            () => (m) => m.id.eq(tenantId)
                        ),
                    ],
                    conflictHandler: dataStoreConflictHandler,
                });

Log output

This is the error I see when immediately trying to delete a comment: ``` LOG DataStore has found a conflict [Function Comment] {"_deleted": null, "_lastChangedAt": 1691010033913, "_version": 2, "archived": 0, "body": "", "createdAt": "2023-08-02T21:00:29.636Z", "id": "f373b642-6982-4ed1-8029-c2aff869b0c1", "owner": null, "parentId": "1f40c987-c109-4b7d-8b1a-188080f86f3d", "tenantId": "7b18c148-6259-4adf-948b-257756e6eb4e", "updatedAt": "2023-08-02T21:00:33.878Z", "userCommentsId": undefined, "visibility": "EVERYONE"} {"_deleted": undefined, "_lastChangedAt": undefined, "_version": 1, "archived": null, "body": null, "createdAt": null, "id": "f373b642-6982-4ed1-8029-c2aff869b0c1", "owner": null, "parentId": null, "tenantId": null, "updatedAt": null, "visibility": null} LOG DataStore error: {"cause": [Error: RetryMutation], "errorType": "Unknown", "localModel": null, "message": "RetryMutation", "model": "Comment", "operation": undefined, "process": "sync", "recoverySuggestion": "Ensure app code is up to date, auth directives exist and are correct on each model, and that server-side data has not been invalidated by a schema change. If the problem persists, search for or create an issue: https://github.com/aws-amplify/amplify-js/issues", "remoteModel": null} LOG Cause: [Error: RetryMutation] ``` On reloading the app I see the same error: ``` LOG DataStore has found a conflict [Function Comment] {"_deleted": true, "_lastChangedAt": 1691010034378, "_version": 3, "archived": 0, "body": "", "createdAt": "2023-08-02T21:00:29.636Z", "id": "f373b642-6982-4ed1-8029-c2aff869b0c1", "owner": null, "parentId": "1f40c987-c109-4b7d-8b1a-188080f86f3d", "tenantId": "7b18c148-6259-4adf-948b-257756e6eb4e", "updatedAt": "2023-08-02T21:00:33.878Z", "userCommentsId": undefined, "visibility": "EVERYONE"} {"_deleted": undefined, "_lastChangedAt": undefined, "_version": 1, "archived": null, "body": null, "createdAt": null, "id": "f373b642-6982-4ed1-8029-c2aff869b0c1", "owner": null, "parentId": null, "tenantId": null, "updatedAt": null, "visibility": null} LOG DataStore error: {"cause": [Error: RetryMutation], "errorType": "Unknown", "localModel": null, "message": "RetryMutation", "model": "Comment", "operation": undefined, "process": "sync", "recoverySuggestion": "Ensure app code is up to date, auth directives exist and are correct on each model, and that server-side data has not been invalidated by a schema change. If the problem persists, search for or create an issue: https://github.com/aws-amplify/amplify-js/issues", "remoteModel": null} LOG Cause: [Error: RetryMutation] ``` and this repeats every time I load up the app. I tried to include a debug log, but it was too long and GitHub rejected the issue.

aws-exports.js

/* eslint-disable */
// WARNING: DO NOT EDIT. This file is automatically generated by AWS Amplify. It will be overwritten.

const awsmobile = {
    "aws_project_region": "eu-west-1",
    "aws_appsync_graphqlEndpoint": "https://lwmusnla5bfhrjmqhjm7miauxa.appsync-api.eu-west-1.amazonaws.com/graphql",
    "aws_appsync_region": "eu-west-1",
    "aws_appsync_authenticationType": "AMAZON_COGNITO_USER_POOLS",
    "aws_cognito_identity_pool_id": "eu-west-1:7b0db40b-57bf-4438-9c96-6e1a94777d3a",
    "aws_cognito_region": "eu-west-1",
    "aws_user_pools_id": "eu-west-1_TE5NS3Bn1",
    "aws_user_pools_web_client_id": "4nr073c46hm9jcdcpnv6a9uhqm",
    "oauth": {},
    "aws_cognito_username_attributes": [],
    "aws_cognito_social_providers": [],
    "aws_cognito_signup_attributes": [
        "EMAIL"
    ],
    "aws_cognito_mfa_configuration": "OFF",
    "aws_cognito_mfa_types": [
        "SMS"
    ],
    "aws_cognito_password_protection_settings": {
        "passwordPolicyMinLength": 8,
        "passwordPolicyCharacters": []
    },
    "aws_cognito_verification_mechanisms": [
        "EMAIL"
    ],
    "aws_user_files_s3_bucket": "platelet26fb7449fb884a3eb4c5fd7539c78dd301103-deev",
    "aws_user_files_s3_bucket_region": "eu-west-1",
    "geo": {
        "amazon_location_service": {
            "region": "eu-west-1",
            "search_indices": {
                "items": [
                    "plateletPlace-deev"
                ],
                "default": "plateletPlace-deev"
            }
        }
    }
};

export default awsmobile;

Manual configuration

No response

Additional configuration

No response

Mobile Device

Android Emulator: Samsung_Galaxy_S8_API_30

Mobile Operating System

No response

Mobile Browser

No response

Mobile Browser Version

No response

Additional information and screenshots

package.json

{
    "name": "mobile",
    "version": "1.0.0",
    "main": "node_modules/expo/AppEntry.js",
    "scripts": {
        "start": "expo start",
        "android": "expo start --android",
        "ios": "expo start --ios",
        "web": "expo start --web",
        "test": "jest --watch --testTimeout=40000"
    },
    "dependencies": {
        "@aws-amplify/datastore-storage-adapter": "^2.0.42",
        "@aws-amplify/ui-react-native": "^1.2.20",
        "@azure/core-asynciterator-polyfill": "^1.0.2",
        "@expo/webpack-config": "^18.0.1",
        "@react-native-async-storage/async-storage": "1.17.11",
        "@react-native-community/netinfo": "9.3.7",
        "@react-navigation/bottom-tabs": "^6.5.8",
        "@react-navigation/material-bottom-tabs": "^6.2.16",
        "@react-navigation/native-stack": "^6.9.13",
        "@reduxjs/toolkit": "^1.9.5",
        "amazon-cognito-identity-js": "^6.3.1",
        "aws-amplify": "^5.3.6",
        "core-js": "^3.31.1",
        "expo": "~48.0.18",
        "expo-blur": "~12.2.2",
        "expo-clipboard": "~4.1.2",
        "expo-file-system": "~15.2.2",
        "expo-haptics": "~12.2.1",
        "expo-sqlite": "~11.1.1",
        "expo-status-bar": "~1.4.4",
        "faker": "5.5.3",
        "moment": "^2.29.4",
        "moment-timezone": "^0.5.43",
        "react": "18.2.0",
        "react-content-loader": "^6.2.1",
        "react-native": "0.71.8",
        "react-native-gesture-handler": "~2.9.0",
        "react-native-get-random-values": "~1.8.0",
        "react-native-hold-menu": "^0.1.6",
        "react-native-paper": "^5.9.1",
        "react-native-paper-dates": "^0.18.12",
        "react-native-reanimated": "~2.14.4",
        "react-native-safe-area-context": "4.5.0",
        "react-native-screens": "~3.20.0",
        "react-native-svg": "13.4.0",
        "react-native-unimodules": "^0.14.10",
        "react-native-url-polyfill": "^2.0.0",
        "react-native-web": "~0.18.10",
        "react-redux": "^8.1.1",
        "redux-saga": "^1.2.3"
    },
    "devDependencies": {
        "@babel/core": "^7.20.0",
        "@testing-library/jest-native": "^5.4.2",
        "@testing-library/react-native": "^12.1.3",
        "@types/jest": "^29.5.3",
        "@types/react": "~18.0.14",
        "jest-expo": "^49.0.0",
        "mock-async-storage": "^2.2.0",
        "react-native-dotenv": "^3.4.9",
        "typescript": "^4.9.4"
    },
    "private": true
}
manueliglesias commented 1 year ago

Hi @duckbytes,

If I only delete the record, everything works fine. If I modify the record before deleting it, I get conflict errors that are never resolved.

This sounds like a bug in DataStore, I'll label the issue as such so we take a closer look at it.

Meanwhile, can you await for the record to be modified? E.g. waiting for the outboxMutationProcessed event before issuing the delete?

I thought a better solution to this problem would be to instead delete the body during the delete mutation with a custom override, but I wasn't able to figure out how to do it.

Yeah, that's possible but I think extending the amplify generated resolvers is a better choice in this case. You would be adding a resolver function into a slot that would do a PutItem with the redacted field before doing the DeleteItem (Pipeline resolvers offer the ability to serially execute operations against data sources).

duckbytes commented 1 year ago

After some more testing, the repeated mutations attempt seem to occur any time there is any conflict.

This is how my conflict handler looks:

import { DISCARD } from "@aws-amplify/datastore";
import {
    SyncConflict,
    PersistentModel,
    PersistentModelConstructor,
} from "@aws-amplify/datastore";
import * as models from "../../models";
import determineTaskStatus from "../../utilities/determineTaskStatus";

const dataStoreConflictHandler = async (
    conflict: SyncConflict
): Promise<symbol | PersistentModel> => {
    const { modelConstructor, localModel, remoteModel } = conflict;
    console.log(
        "DataStore has found a conflict",
        modelConstructor,
        remoteModel,
        localModel
    );
    if (remoteModel.archived === 1) {
        return DISCARD;
    }
    if (
        modelConstructor ===
        (models.Task as PersistentModelConstructor<models.Task>)
    ) {
        const remote = remoteModel as models.Task;
        const local = localModel as models.Task;
        let newModel = models.Task.copyOf(remote, (task) => {
            task.timePickedUp = remote.timePickedUp || local.timePickedUp;
            task.timeDroppedOff = remote.timeDroppedOff || local.timeDroppedOff;
            task.timeRiderHome = remote.timeRiderHome || local.timeRiderHome;
            task.timeCancelled = remote.timeCancelled || local.timeCancelled;
            task.timeRejected = remote.timeRejected || local.timeRejected;
            task.timePickedUpSenderName =
                remote.timePickedUpSenderName || local.timePickedUpSenderName;
            task.timeDroppedOffRecipientName =
                remote.timeDroppedOffRecipientName ||
                local.timeDroppedOffRecipientName;
        });
        console.log("Resolved task conflict result:", newModel);
        const status = await determineTaskStatus(newModel);
        console.log("Updating task status to", status);
        newModel = models.Task.copyOf(newModel, (task) => {
            task.status = status;
        });
        const { createdAt, updatedAt, tenantId, archived, ...rest } = newModel;
        return rest;
    } else if (
        modelConstructor ===
        (models.Comment as PersistentModelConstructor<models.Comment>)
    ) {
        const { createdAt, updatedAt, tenantId, archived, ...rest } =
            remoteModel;
        return rest;
    }
    return DISCARD;
};

export default dataStoreConflictHandler;

I'm doing tests with the Task model as well, which looks like this in the schema:

type Task
@auth(rules: [
  {allow: private, operations: [read]},
  {allow: groups, groups: ["COORDINATOR", "RIDER", "ADMIN"], operations: [create, read, update]},
])
@model {
  id: ID!
  tenantId: ID! @index(name: "byTenantId", queryField: "listTasksByTenantId", sortKeyFields: ["createdAt"])
    @auth(rules: [
      {allow: private, operations: [read]},
      {allow: groups, groups: ["COORDINATOR", "RIDER", "ADMIN"], operations: [create, read]},
    ])
  createdAt: String @auth(rules: [{allow: private, operations: [read]}])
  createdBy: User @belongsTo
  dateCreated: AWSDate!
  timeOfCall: AWSDateTime
  timePickedUp: AWSDateTime
    @auth(rules: [
      {allow: private, operations: [read]},
      {allow: groups, groups: ["COORDINATOR", "RIDER", "ADMIN"], operations: [create, read, update, delete]},
    ])
  timePickedUpSenderName: String
    @auth(rules: [
      {allow: private, operations: [read]},
      {allow: groups, groups: ["COORDINATOR", "RIDER", "ADMIN"], operations: [create, read, update, delete]},
    ])
  timeDroppedOff: AWSDateTime
    @auth(rules: [
      {allow: private, operations: [read]},
      {allow: groups, groups: ["COORDINATOR", "RIDER", "ADMIN"], operations: [create, read, update, delete]},
    ])
  timeDroppedOffRecipientName: String
    @auth(rules: [
      {allow: private, operations: [read]},
      {allow: groups, groups: ["COORDINATOR", "RIDER", "ADMIN"], operations: [create, read, update, delete]},
    ])
  timeCancelled: AWSDateTime
    @auth(rules: [
      {allow: private, operations: [read]},
      {allow: groups, groups: ["COORDINATOR", "RIDER", "ADMIN"], operations: [create, read, update, delete]},
    ])
  timeRejected: AWSDateTime
    @auth(rules: [
      {allow: private, operations: [read]},
      {allow: groups, groups: ["COORDINATOR", "RIDER", "ADMIN"], operations: [create, read, update, delete]},
    ])
  timeRiderHome: AWSDateTime
    @auth(rules: [
      {allow: private, operations: [read]},
      {allow: groups, groups: ["COORDINATOR", "RIDER", "ADMIN"], operations: [create, read, update, delete]},
    ])
  requesterContact: AddressAndContactDetails
  pickUpLocationId: ID @index(name: "byPickUpLocation")
    @auth(rules: [
      {allow: private, operations: [read]},
      {allow: groups, groups: ["COORDINATOR", "RIDER", "ADMIN"], operations: [create, read, update, delete]},
    ])
  dropOffLocationId: ID @index(name: "byDropOffLocation")
    @auth(rules: [
      {allow: private, operations: [read]},
      {allow: groups, groups: ["COORDINATOR", "RIDER", "ADMIN"], operations: [create, read, update, delete]},
    ])
  establishmentLocationId: ID @index(name: "byEstasblishmentLocation")
  pickUpLocation: Location @belongsTo(fields: ["pickUpLocationId"])
  dropOffLocation: Location @belongsTo(fields: ["dropOffLocationId"])
  establishmentLocation: Location @belongsTo(fields: ["establishmentLocationId"])
  riderResponsibility: String
    @auth(rules: [
      {allow: private, operations: [read]},
      {allow: groups, groups: ["COORDINATOR", "RIDER", "ADMIN"], operations: [create, read, update, delete]},
    ])
  assignees: [TaskAssignee] @hasMany
  priority: Priority
    @auth(rules: [
      {allow: private, operations: [read]},
      {allow: groups, groups: ["COORDINATOR", "RIDER", "ADMIN"], operations: [create, read, update, delete]},
    ])
  deliverables: [Deliverable] @hasMany
  comments: [Comment] @hasMany(indexName: "byParent", fields: ["id"])
  status: TaskStatus @index(name: "byStatus", queryField: "tasksByStatus")
  isRiderUsingOwnVehicle: Int @default(value: "0")
  archived: Int @default(value: "0") @index(name: "byArchivedStatus", queryField: "tasksByArchivedStatus", sortKeyFields: ["status"])
    @auth(rules: [
      {allow: private, operations: [read]},
      {allow: groups, groups: ["COORDINATOR", "RIDER", "ADMIN"], operations: [create, read]},
    ])
}

And if I intentionally create a conflict, it does seem to resolve it fine. The right data is saved to the server when a conflict occurs.

However if I reload the app, the conflict handler gets run every time and I get this error message from DataStore:

08-20 16:47:41.266 17734 26056 I ReactNativeJS: 'DataStore error:', { recoverySuggestion: 'Ensure app code is up to date, auth directives exist and are correct on each model, and that server-side data has not been invalidated by a schema change. If the problem persists, search for or create an issue: https://github.com/aws-amplify/amplify-js/issues',
08-20 16:47:41.266 17734 26056 I ReactNativeJS:   localModel: null,
08-20 16:47:41.266 17734 26056 I ReactNativeJS:   message: 'RetryMutation',
08-20 16:47:41.266 17734 26056 I ReactNativeJS:   model: 'Task',
08-20 16:47:41.266 17734 26056 I ReactNativeJS:   operation: undefined,
08-20 16:47:41.266 17734 26056 I ReactNativeJS:   errorType: 'Unknown',
08-20 16:47:41.266 17734 26056 I ReactNativeJS:   process: 'sync',
08-20 16:47:41.266 17734 26056 I ReactNativeJS:   remoteModel: null,
08-20 16:47:41.266 17734 26056 I ReactNativeJS:   cause: { [Error: RetryMutation] nonRetryable: true } }
08-20 16:47:41.267 17734 26056 I ReactNativeJS: 'Cause:', { [Error: RetryMutation] nonRetryable: true }

This only happens in my React native version. Either it's because I'm using a newer version of Amplify (5.3.6 vs 4.3.46 in the web version) or because of some other reason. I can't test the web version with the latest version of Amplify.

If I just return DISCARD from the conflict resolver function, it still does repeated attempts at the mutation.

@manueliglesias thanks for the suggestions. What I tried before was actually extending the resolvers (sorry for my previous wrong choice of words).

I gave it another try. I made a file Mutation.deleteComment.postAuth.2.req.vtl in resolvers with this:

#if( $ctx.stash.metadata.modelObjectKey )
  #set( $Key = $ctx.stash.metadata.modelObjectKey )
#else
  #set( $Key = {
  "id":   $util.dynamodb.toDynamoDB($args.input.id)
} )
#end
#set( $UpdateItem = {
  "version": "2018-05-29",
  "operation": "UpdateItem",
  "key": $Key,
  "update": {
  "expression": "SET #bodyfield = :body",
  "expressionNames": {
      "#bodyfield": "body"
  },
  "expressionValues": {
      ":body": { "S" : "" }
  },
  "_version": $util.defaultIfNull($args.input["_version"], 0)
  }
})
$utils.toJson({})

and pushed it up to my dev environment. Somehow it completely broke my environment and I wasn't able to recover it. The error I was getting back from the API looked like this:

Screenshot from 2023-08-10 18-50-41

byTenantId is a GSI I have on every model.

I'd really like some advice if possible about how I can do this safely. It's hard to find good documentation or examples and it'd be really bad if I somehow broke a production environment this way.

chrisbonifacio commented 11 months ago

Hi @duckbytes Apologies for the delayed response. We've looked into this internally and came to a working solution.

We think the most reliable way to solve this is by writing backend business logic in VTL to update the record before deleting it. This way we can guarantee that the field gets cleared as part of before every delete operation without relying on the clientside apps.

To do this we’ll insert new request and response resolver functions into our DeleteComment resolver pipeline. We will also need to modify the existing DeleteComment data resolver and add an override to connect the new resolver functions with the correct data source.

Step 1 - create new resolver functions

Add the following 2 files into ./amplify/backend/api/{your-api-name}/resolvers

  1. Mutation.deleteComment.preUpdate.res.vtl
  2. Mutation.deleteComment.preUpdate.req.vtl

Here’s the source for these 2 functions:

Mutation.deleteComment.preUpdate.res.vtl

#set( $Key = {
  "id": $util.dynamodb.toDynamoDB($ctx.args.input.id)
} )

#set( $Version = $util.defaultIfNull($args.input["_version"], 1) )

#set( $UpdateItem = {
  "version": "2018-05-29",
  "operation": "UpdateItem",
  "key": $Key,
  "update": {
    "expression": "SET #bodyfield = :body",
    "expressionNames": {
        "#bodyfield": "body"
    },
    "expressionValues": {
        ":body": { "S" : "" }
    }
  },
  "_version": $Version
})

$utils.toJson($UpdateItem)

Mutation.deleteComment.preUpdate.res.vtl

## save the updated _version to stash. We will use it in the data resolver
$util.qr($ctx.stash.put("updatedVersion", $ctx.result["_version"]))

$util.toJson({"version":"2018-05-29","payload":{}})

Step 2 - override existing delete Comment data resolver function

Copy the existing delete Comment data resolver into the custom resolvers folder

./amplify/backend/api/{your-api-name}/build/resolvers/Mutation.deleteComment.req.vtl

into

./amplify/backend/api/{your-api-name}/resolvers/Mutation.deleteComment.req.vtl

Step 3 - modify data resolver

Open the file we copied over in step 2 (./amplify/backend/api/{your-api-name}/resolvers/Mutation.deleteComment.req.vtl)

Towards the bottom of the file you will see this line

$util.qr($DeleteRequest.put("_version", $util.defaultIfNull($args.input["_version"], 0)))

Replace it with

$util.qr($DeleteRequest.put("_version", $ctx.stash.updatedVersion))

This allows us to perform the delete with the newly updated version of the record (after the update mutation we defined in step 1)

Step 4 - run compile

We’re not ready to deploy the changes yet. We still need to point the new resolvers at the correct data source (the Comment table).

Before we can do that we need to figure out the correct metadata for this override.

Run the following command from the root of your project

amplify api gql-compile

Now open ./amplify/backend/api/{your-api-name}/build/stacks/Comment.json and search for the new file name we added in step 1 (Mutation.deleteComment.preUpdate.req.vtl)

Take note of the property name for the function configuration (red box in the screen shot below). We’ll need it in the next step.

Step 5 - add override

Run the following command from the root of your project

amplify override api

This will generate a new file: ./amplify/backend/api/{your-api-name}/override.ts

Open that file and paste the following contents:

import { AmplifyApiGraphQlResourceStackTemplate } from "@aws-amplify/cli-extensibility-helper";

export function override(resources: AmplifyApiGraphQlResourceStackTemplate) {
  const model = resources.models["Comment"];

  // edit and paste the function name from stacks/Comment.json
  const fnName = "MutationdeleteCommentpreUpdate0FunctionMutationdeleteCommentpreUpdate0Function.AppSyncFunction"

  model.appsyncFunctions[fnName].dataSourceName = "CommentTable";
}

For fnName you’ll need to modify the value from stacks/Comment.json in the following way:

Copy the value immediately before AppSyncFunctionxxxxx (see bold substring below) MutationdeleteCommentpreUpdate0FunctionMutationdeleteCommentpreUpdate0FunctionAppSyncFunction3B09C3BA And then append .AppSyncFunction to the end of it.

In other words: MutationdeleteCommentpreUpdate0FunctionMutationdeleteCommentpreUpdate0FunctionAppSyncFunction3B09C3BA

becomes

MutationdeleteCommentpreUpdate0FunctionMutationdeleteCommentpreUpdate0Function.AppSyncFunction

That’s the value we’ll use for fnName

Step 6 - deploy changes

Now we can run amplify push to deploy these changes.

We should also update the client-side code that was shared in the issue comments above, removing the update call. Every delete Comment mutation will now clear the value of the body field in the service before performing the delete.

duckbytes commented 11 months ago

Hi @chrisbonifacio. Thanks very much for your detailed instructions.

I think I followed the steps correctly, but it didn't work for me. A couple observations:

In step one you refer to the same file for both blocks of code. I assumed the first one was the res file and second one was the req file.

In step 4 you reference a screenshot that wasn't attached, but I'm pretty sure I got this step right from your description.

The first problem I came across was getting this error on a new deployment:

🛑 The following resources failed to deploy:
Resource Name: MutationdeleteCommentpreUpdate0FunctionMutationdeleteCommentpreUpdate0FunctionAppSyncFunction3B09C3BA (AWS::AppSync::FunctionConfiguration)
Event Type: create
Reason: No data source found named CommentTable (Service: AWSAppSync; Status Code: 404; Error Code: NotFoundException; Request ID: 790810fc-f4e5-40ee-bf96-0f4bb79fbbfb; Proxy: null)

🛑 Resource is not in the state stackUpdateComplete
Name: MutationdeleteCommentpreUpdate0FunctionMutationdeleteCommentpreUpdate0FunctionAppSyncFunction3B09C3BA (AWS::AppSync::FunctionConfiguration), Event Type: create, Reason: No data source found named CommentTable (Service: AWSAppSync; Status Code: 404; Error Code: NotFoundException; Request ID: 790810fc-f4e5-40ee-bf96-0f4bb79fbbfb; Proxy: null), IsCustomResource: false

Learn more at: https://docs.amplify.aws/cli/project/troubleshooting/

Session Identifier: d88d5109-c82f-4aa2-93af-80edd7c14148

It seems like if I don't include this particular override I can make a deployment with no error. If I then add the override and update the stack, everything works. I do need to be able to make new deployments though, so this would be a blocker for me.

After successfully making the deployment, I'm still receiving the missing index error. On first load everything works, but as soon as I make any mutation, the indexes are deleted.

Screenshot from 2023-09-19 22-04-36

The specific error coming back from the API is:

The table does not have the specified index: deliverableTypesByTenantId (Service: DynamoDb, Status Code: 400, Request ID: 389F0TFL0FL3AVBG0E80KDDHPRVV4KQNSO5AEMVJF66Q9ASUAAJG)

but with a different index name. It happens on every sync request for every model with the tenantId index.

To complicate things a bit more, I already have some overrides:

import { AmplifyApiGraphQlResourceStackTemplate } from "@aws-amplify/cli-extensibility-helper";
import { overrideDataSourceByFileName } from "./overrideHelpers"; // <<== the helper file in this repo

export const override = (resources: AmplifyApiGraphQlResourceStackTemplate) => {
    overrideDataSourceByFileName(
        resources,
        "Mutation.createTaskAssignee.postAuth.2", // <== The name of your file (without the extension)
        "TaskAssignee", // <== The model that this resolver falls within
        "TaskTable" // <== The new datasource that you want to use
    );
    overrideDataSourceByFileName(
        resources,
        "Mutation.createComment.postAuth.2", // <== The name of your file (without the extension)
        "Comment", // <== The model that this resolver falls within
        "UserTable" // <== The new datasource that you want to use
    );

    const model = resources.models["Comment"];
    // edit and paste the function name from stacks/Comment.json
    const fnName =
        "MutationdeleteCommentpreUpdate0FunctionMutationdeleteCommentpreUpdate0Function.AppSyncFunction";

    model.appsyncFunctions[fnName].dataSourceName = "CommentTable";
};

This is what I deployed with.

The other comment override is to prevent users from creating comments with an author other than themselves, but only effects the createComment mutation.

overrideHelpers.ts

Another thing I was wondering about is how I've declared my indexes. I did something a bit dumb at the start of my project and copy pasted them with the same name:

tenantId: ID! @index(name: "byTenantId")

so like that but on multiple models. I realised later it would have been better to just not give it a name and wondered if it might be causing issues having all indexes share a name. The entire schema for reference.

But I tried a push with just `@index' and still got the same deleted indexes error.

chrisbonifacio commented 10 months ago

Hey @duckbytes , apologies for the delay. We're reviewing your latest comment and looking into it. We'll report back with any findings in the near future. Thank you for your patience!